diff --git a/CHANGELOG.md b/CHANGELOG.md index c492f4e3e..63af2105d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -289,8 +289,8 @@ - Use vCard in TestContext.add_or_lookup_contact(). - Remove test_group_with_removed_message_id. -- Use add_or_lookup_email_contact() in get_chat(). -- Use add_or_lookup_email_contact in test_setup_contact_ex. +- Use add_or_lookup_address_contact() in get_chat(). +- Use add_or_lookup_address_contact in test_setup_contact_ex. - Use vCards more in Python tests. - Use TestContextManager in more tests. - Use vCards to create contacts in more Rust tests. diff --git a/assets/icon-address-contact.png b/assets/icon-address-contact.png new file mode 100644 index 000000000..9262dbdac Binary files /dev/null and b/assets/icon-address-contact.png differ diff --git a/assets/icon-address-contact.svg b/assets/icon-address-contact.svg new file mode 100644 index 000000000..0cb9d97a8 --- /dev/null +++ b/assets/icon-address-contact.svg @@ -0,0 +1,47 @@ + + + + + + + diff --git a/assets/self-reporting-bot.vcf b/assets/self-reporting-bot.vcf new file mode 100644 index 000000000..0e38035fe --- /dev/null +++ b/assets/self-reporting-bot.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +EMAIL:self_reporting@testrun.org +FN:Statistics bot +KEY:data:application/pgp-keys;base64,xjMEZbfBlBYJKwYBBAHaRw8BAQdABpLWS2PUIGGo4pslVt4R8sylP5wZihmhf1DTDr3oCMPNHDxzZWxmX3JlcG9ydGluZ0B0ZXN0cnVuLm9yZz7CiwQQFggAMwIZAQUCZbfBlAIbAwQLCQgHBhUICQoLAgMWAgEWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohD8dAQCQV7CoH6UP4PD+NqI4kW5tbbqdh2AnDROg60qotmLExAEAxDfd3QHAK9f8b9qQUbLmHIztCLxhEuVbWPBEYeVW0gvOOARlt8GUEgorBgEEAZdVAQUBAQdAMBUhYoAAcI625vGZqnM5maPX4sGJ7qvJxPAFILPy6AcDAQgHwngEGBYIACAFAmW3wZQCGwwWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohPwCAQCvzk1ObIkj2GqsuIfaULlgdnfdZY8LNary425CEfHZDQD5AblXVrlMO1frdlc/Vo9z3pEeCrfYdD7ITD3/OeVoiQ4= +REV:20250412T195751Z +END:VCARD diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index b2fca4d1b..233f19242 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -3838,6 +3838,21 @@ int dc_chat_can_send (const dc_chat_t* chat); int dc_chat_is_protected (const dc_chat_t* chat); +/** + * Check if the chat is encrypted. + * + * 1:1 chats with key-contacts and group chats with key-contacts + * are encrypted. + * 1:1 chats with emails contacts and ad-hoc groups + * created for email threads are not encrypted. + * + * @memberof dc_chat_t + * @param chat The chat object. + * @return 1=chat is encrypted, 0=chat is not encrypted. + */ +int dc_chat_is_encrypted (const dc_chat_t *chat); + + /** * Checks if the chat was protected, and then an incoming message broke this protection. * @@ -6886,6 +6901,7 @@ void dc_event_unref(dc_event_t* event); /// "End-to-end encryption preferred." /// /// Used to build the string returned by dc_get_contact_encrinfo(). +/// @deprecated 2025-06-05 #define DC_STR_E2E_PREFERRED 34 /// "%1$s verified" @@ -6898,12 +6914,14 @@ void dc_event_unref(dc_event_t* event); /// /// Used in status messages. /// - %1$s will be replaced by the name of the contact that cannot be verified +/// @deprecated 2025-06-05 #define DC_STR_CONTACT_NOT_VERIFIED 36 /// "Changed setup for %1$s." /// /// Used in status messages. /// - %1$s will be replaced by the name of the contact with the changed setup +/// @deprecated 2025-06-05 #define DC_STR_CONTACT_SETUP_CHANGED 37 /// "Archived chats" @@ -7293,6 +7311,7 @@ void dc_event_unref(dc_event_t* event); /// "%1$s changed their address from %2$s to %3$s" /// /// Used as an info message to chats with contacts that changed their address. +/// @deprecated 2025-06-05 #define DC_STR_AEAP_ADDR_CHANGED 122 /// "You changed your email address from %1$s to %2$s. @@ -7599,6 +7618,7 @@ void dc_event_unref(dc_event_t* event); /// "The contact must be online to proceed. This process will continue automatically in background." /// /// Used as info message. +/// @deprecated 2025-06-05 #define DC_STR_SECUREJOIN_TAKES_LONGER 192 /// "Contact". Deprecated, currently unused. diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 884d89476..3567879cc 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -3153,6 +3153,18 @@ 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_encrypted(chat: *mut dc_chat_t) -> libc::c_int { + if chat.is_null() { + eprintln!("ignoring careless call to dc_chat_is_encrypted()"); + return 0; + } + let ffi_chat = &*chat; + + block_on(ffi_chat.chat.is_encrypted(&ffi_chat.context)) + .unwrap_or_log_default(&ffi_chat.context, "Failed dc_chat_is_encrypted") 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() { @@ -4303,6 +4315,7 @@ pub unsafe extern "C" fn dc_contact_get_verifier_id(contact: *mut dc_contact_t) .context("failed to get verifier") .log_err(ctx) .unwrap_or_default() + .unwrap_or_default() .unwrap_or_default(); verifier_contact_id.to_u32() diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 3b4414297..558643df6 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -354,6 +354,20 @@ impl CommandApi { Ok(ctx.get_blobdir().to_str().map(|s| s.to_owned())) } + /// If there was an error while the account was opened + /// and migrated to the current version, + /// then this function returns it. + /// + /// This function is useful because the key-contacts migration could fail due to bugs + /// and then the account will not work properly. + /// + /// After opening an account, the UI should call this function + /// and show the error string if one is returned. + async fn get_migration_error(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + Ok(ctx.get_migration_error()) + } + /// Copy file to blob dir. async fn copy_to_blob_dir(&self, account_id: u32, path: String) -> Result { let ctx = self.get_context(account_id).await?; @@ -1542,15 +1556,6 @@ impl CommandApi { Ok(()) } - /// Resets contact encryption. - async fn reset_contact_encryption(&self, account_id: u32, contact_id: u32) -> Result<()> { - let ctx = self.get_context(account_id).await?; - let contact_id = ContactId::new(contact_id); - - contact_id.reset_encryption(&ctx).await?; - Ok(()) - } - /// Sets display name for existing contact. async fn change_contact_name( &self, diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index b1ee3802c..777defe76 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -30,6 +30,29 @@ pub struct FullChat { /// in the contact profile /// if 1:1 chat with this contact exists and is protected. is_protected: bool, + /// True if the chat is encrypted. + /// This means that all messages in the chat are encrypted, + /// and all contacts in the chat are "key-contacts", + /// i.e. identified by the PGP key fingerprint. + /// + /// False if the chat is unencrypted. + /// This means that all messages in the chat are unencrypted, + /// and all contacts in the chat are "address-contacts", + /// i.e. identified by the email address. + /// The UI should mark this chat e.g. with a mail-letter icon. + /// + /// Unencrypted groups are called "ad-hoc groups" + /// and the user can't add/remove members, + /// create a QR invite code, + /// or set an avatar. + /// These options should therefore be disabled in the UI. + /// + /// Note that it can happen that an encrypted chat + /// contains unencrypted messages that were received in core <= v1.159.* + /// and vice versa. + /// + /// See also `is_key_contact` on `Contact`. + is_encrypted: bool, profile_image: Option, //BLOBS ? archived: bool, pinned: bool, @@ -108,6 +131,7 @@ impl FullChat { id: chat_id, name: chat.name.clone(), is_protected: chat.is_protected(), + is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, pinned: chat.get_visibility() == chat::ChatVisibility::Pinned, @@ -159,6 +183,30 @@ pub struct BasicChat { /// in the contact profile /// if 1:1 chat with this contact exists and is protected. is_protected: bool, + + /// True if the chat is encrypted. + /// This means that all messages in the chat are encrypted, + /// and all contacts in the chat are "key-contacts", + /// i.e. identified by the PGP key fingerprint. + /// + /// False if the chat is unencrypted. + /// This means that all messages in the chat are unencrypted, + /// and all contacts in the chat are "address-contacts", + /// i.e. identified by the email address. + /// The UI should mark this chat e.g. with a mail-letter icon. + /// + /// Unencrypted groups are called "ad-hoc groups" + /// and the user can't add/remove members, + /// create a QR invite code, + /// or set an avatar. + /// These options should therefore be disabled in the UI. + /// + /// Note that it can happen that an encrypted chat + /// contains unencrypted messages that were received in core <= v1.159.* + /// and vice versa. + /// + /// See also `is_key_contact` on `Contact`. + is_encrypted: bool, profile_image: Option, //BLOBS ? archived: bool, pinned: bool, @@ -187,6 +235,7 @@ impl BasicChat { id: chat_id, name: chat.name.clone(), is_protected: chat.is_protected(), + is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, pinned: chat.get_visibility() == chat::ChatVisibility::Pinned, diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs index 5a4e2cf26..d229d8c1f 100644 --- a/deltachat-jsonrpc/src/api/types/chat_list.rs +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -30,6 +30,30 @@ pub enum ChatListItemFetchResult { /// showing preview if last chat message is image summary_preview_image: Option, is_protected: bool, + + /// True if the chat is encrypted. + /// This means that all messages in the chat are encrypted, + /// and all contacts in the chat are "key-contacts", + /// i.e. identified by the PGP key fingerprint. + /// + /// False if the chat is unencrypted. + /// This means that all messages in the chat are unencrypted, + /// and all contacts in the chat are "address-contacts", + /// i.e. identified by the email address. + /// The UI should mark this chat e.g. with a mail-letter icon. + /// + /// Unencrypted groups are called "ad-hoc groups" + /// and the user can't add/remove members, + /// create a QR invite code, + /// or set an avatar. + /// These options should therefore be disabled in the UI. + /// + /// Note that it can happen that an encrypted chat + /// contains unencrypted messages that were received in core <= v1.159.* + /// and vice versa. + /// + /// See also `is_key_contact` on `Contact`. + is_encrypted: bool, is_group: bool, fresh_message_counter: usize, is_self_talk: bool, @@ -137,6 +161,7 @@ pub(crate) async fn get_chat_list_item_by_id( summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum summary_preview_image, is_protected: chat.is_protected(), + is_encrypted: chat.is_encrypted(ctx).await?, is_group: chat.get_type() == Chattype::Group, fresh_message_counter, is_self_talk: chat.is_self_talk(), diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs index eb85cf35c..3d8ce98df 100644 --- a/deltachat-jsonrpc/src/api/types/contact.rs +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -19,6 +19,16 @@ pub struct ContactObject { profile_image: Option, // BLOBS name_and_addr: String, is_blocked: bool, + + /// Is the contact a key contact. + is_key_contact: bool, + + /// Is encryption available for this contact. + /// + /// This can only be true for key-contacts. + /// However, it is possible to have a key-contact + /// for which encryption is not available because we don't have a key yet, + /// e.g. if we just scanned the fingerprint from a QR code. e2ee_avail: bool, /// True if the contact can be added to verified groups. @@ -67,6 +77,7 @@ impl ContactObject { let verifier_id = contact .get_verifier_id(context) .await? + .flatten() .map(|contact_id| contact_id.to_u32()); Ok(ContactObject { @@ -80,6 +91,7 @@ impl ContactObject { profile_image, //BLOBS name_and_addr: contact.get_name_n_addr(), is_blocked: contact.is_blocked(), + is_key_contact: contact.is_key_contact(), e2ee_avail: contact.e2ee_avail(context).await?, is_verified, is_profile_verified, diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 511d79f02..3ad81bd12 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -59,6 +59,13 @@ pub struct MessageObject { // summary - use/create another function if you need it subject: String, + + /// True if the message was correctly encrypted&signed, false otherwise. + /// Historically, UIs showed a small padlock on the message then. + /// + /// Today, the UIs should instead show a small email-icon on the message + /// if `show_padlock` is `false`, + /// and nothing if it is `true`. show_padlock: bool, is_setupmessage: bool, is_info: bool, diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index 2f5188f75..2c9080050 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -20,7 +20,6 @@ use deltachat::log::LogExt; use deltachat::message::{self, Message, MessageState, MsgId, Viewtype}; use deltachat::mimeparser::SystemMessage; use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data}; -use deltachat::peerstate::*; use deltachat::qr::*; use deltachat::qr_code_generator::create_qr_svg; use deltachat::reaction::send_reaction; @@ -35,14 +34,6 @@ use tokio::fs; /// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4. async fn reset_tables(context: &Context, bits: i32) { println!("Resetting tables ({bits})..."); - if 0 != bits & 2 { - context - .sql() - .execute("DELETE FROM acpeerstates;", ()) - .await - .unwrap(); - println!("(2) Peerstates reset."); - } if 0 != bits & 4 { context .sql() @@ -277,7 +268,7 @@ async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> { async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()> { for contact_id in contacts { - let mut line2 = "".to_string(); + let line2 = "".to_string(); let contact = Contact::get_by_id(context, *contact_id).await?; let name = contact.get_display_name(); let addr = contact.get_addr(); @@ -296,15 +287,6 @@ async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<() verified_str, if !addr.is_empty() { addr } else { "addr unset" } ); - let peerstate = Peerstate::from_addr(context, addr) - .await - .expect("peerstate error"); - if peerstate.is_some() && *contact_id != ContactId::SELF { - line2 = format!( - ", prefer-encrypt={}", - peerstate.as_ref().unwrap().prefer_encrypt - ); - } println!("Contact#{}: {}{}", *contact_id, line, line2); } @@ -514,7 +496,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed"); } "reset" => { - ensure!(!arg1.is_empty(), "Argument missing: 1=jobs, 2=peerstates, 4=private keys, 8=rest but server config"); + ensure!( + !arg1.is_empty(), + "Argument missing: 4=private keys, 8=rest but server config" + ); let bits: i32 = arg1.parse()?; ensure!(bits < 16, " must be lower than 16."); reset_tables(&context, bits).await; diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py index c2444afbd..1c0389064 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py @@ -37,10 +37,6 @@ class Contact: """Delete contact.""" self._rpc.delete_contact(self.account.id, self.id) - def reset_encryption(self) -> None: - """Reset contact encryption.""" - self._rpc.reset_contact_encryption(self.account.id, self.id) - def set_name(self, name: str) -> None: """Change the name of this contact.""" self._rpc.change_contact_name(self.account.id, self.id, name) diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index 03ade4d04..31f483520 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -1,5 +1,4 @@ import logging -import time import pytest @@ -16,14 +15,14 @@ def test_qr_setup_contact(acfactory, tmp_path) -> None: alice.wait_for_securejoin_inviter_success() # Test that Alice verified Bob's profile. - alice_contact_bob = alice.get_contact_by_addr(bob.get_config("addr")) + alice_contact_bob = alice.create_contact(bob) alice_contact_bob_snapshot = alice_contact_bob.get_snapshot() assert alice_contact_bob_snapshot.is_verified bob.wait_for_securejoin_joiner_success() # Test that Bob verified Alice's profile. - bob_contact_alice = bob.get_contact_by_addr(alice.get_config("addr")) + bob_contact_alice = bob.create_contact(alice) bob_contact_alice_snapshot = bob_contact_alice.get_snapshot() assert bob_contact_alice_snapshot.is_verified @@ -84,7 +83,7 @@ def test_qr_securejoin(acfactory, protect): bob.wait_for_securejoin_joiner_success() # Test that Alice verified Bob's profile. - alice_contact_bob = alice.get_contact_by_addr(bob.get_config("addr")) + alice_contact_bob = alice.create_contact(bob) alice_contact_bob_snapshot = alice_contact_bob.get_snapshot() assert alice_contact_bob_snapshot.is_verified @@ -93,7 +92,7 @@ def test_qr_securejoin(acfactory, protect): assert snapshot.chat.get_basic_snapshot().is_protected == protect # Test that Bob verified Alice's profile. - bob_contact_alice = bob.get_contact_by_addr(alice.get_config("addr")) + bob_contact_alice = bob.create_contact(alice) bob_contact_alice_snapshot = bob_contact_alice.get_snapshot() assert bob_contact_alice_snapshot.is_verified @@ -101,7 +100,7 @@ def test_qr_securejoin(acfactory, protect): # Alice observes securejoin protocol and verifies Bob on second device. alice2.start_io() alice2.wait_for_securejoin_inviter_success() - alice2_contact_bob = alice2.get_contact_by_addr(bob.get_config("addr")) + alice2_contact_bob = alice2.create_contact(bob) alice2_contact_bob_snapshot = alice2_contact_bob.get_snapshot() assert alice2_contact_bob_snapshot.is_verified @@ -213,72 +212,8 @@ def test_setup_contact_resetup(acfactory) -> None: bob.wait_for_securejoin_joiner_success() -def test_verified_group_recovery(acfactory) -> None: - """Tests verified group recovery by reverifying a member and sending a message in a group.""" - ac1, ac2, ac3 = acfactory.get_online_accounts(3) - - logging.info("ac1 creates verified group") - chat = ac1.create_group("Verified group", protect=True) - assert chat.get_basic_snapshot().is_protected - - logging.info("ac2 joins verified group") - qr_code = chat.get_qr_code() - ac2.secure_join(qr_code) - ac2.wait_for_securejoin_joiner_success() - - # ac1 has ac2 directly verified. - ac1_contact_ac2 = ac1.get_contact_by_addr(ac2.get_config("addr")) - assert ac1_contact_ac2.get_snapshot().verifier_id == SpecialContactId.SELF - - logging.info("ac3 joins verified group") - ac3_chat = ac3.secure_join(qr_code) - ac3.wait_for_securejoin_joiner_success() - ac3.wait_for_incoming_msg_event() # Member added - - logging.info("ac2 logs in on a new device") - ac2 = acfactory.resetup_account(ac2) - - logging.info("ac2 reverifies with ac3") - qr_code = ac3.get_qr_code() - ac2.secure_join(qr_code) - ac2.wait_for_securejoin_joiner_success() - - logging.info("ac3 sends a message to the group") - assert len(ac3_chat.get_contacts()) == 3 - ac3_chat.send_text("Hi!") - - snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot() - assert snapshot.text == "Hi!" - - msg_id = ac2.wait_for_incoming_msg_event().msg_id - message = ac2.get_message_by_id(msg_id) - snapshot = message.get_snapshot() - assert snapshot.text == "Hi!" - - # ac1 contact is verified for ac2 because ac3 gossiped ac1 key in the "Hi!" message. - ac1_contact = ac2.get_contact_by_addr(ac1.get_config("addr")) - assert ac1_contact.get_snapshot().is_verified - - # ac2 can write messages to the group. - snapshot.chat.send_text("Works again!") - - snapshot = ac3.get_message_by_id(ac3.wait_for_incoming_msg_event().msg_id).get_snapshot() - assert snapshot.text == "Works again!" - - snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot() - assert snapshot.text == "Works again!" - - ac1_chat_messages = snapshot.chat.get_messages() - ac2_addr = ac2.get_config("addr") - assert ac1_chat_messages[-2].get_snapshot().text == f"Changed setup for {ac2_addr}" - - # ac2 is now verified by ac3 for ac1 - ac1_contact_ac3 = ac1.get_contact_by_addr(ac3.get_config("addr")) - assert ac1_contact_ac2.get_snapshot().verifier_id == ac1_contact_ac3.id - - def test_verified_group_member_added_recovery(acfactory) -> None: - """Tests verified group recovery by reverifiying than removing and adding a member back.""" + """Tests verified group recovery by reverifying then removing and adding a member back.""" ac1, ac2, ac3 = acfactory.get_online_accounts(3) logging.info("ac1 creates verified group") @@ -291,7 +226,7 @@ def test_verified_group_member_added_recovery(acfactory) -> None: ac2.wait_for_securejoin_joiner_success() # ac1 has ac2 directly verified. - ac1_contact_ac2 = ac1.get_contact_by_addr(ac2.get_config("addr")) + ac1_contact_ac2 = ac1.create_contact(ac2) assert ac1_contact_ac2.get_snapshot().verifier_id == SpecialContactId.SELF logging.info("ac3 joins verified group") @@ -299,6 +234,8 @@ def test_verified_group_member_added_recovery(acfactory) -> None: ac3.wait_for_securejoin_joiner_success() ac3.wait_for_incoming_msg_event() # Member added + ac3_contact_ac2_old = ac3.create_contact(ac2) + logging.info("ac2 logs in on a new device") ac2 = acfactory.resetup_account(ac2) @@ -311,21 +248,10 @@ def test_verified_group_member_added_recovery(acfactory) -> None: assert len(ac3_chat.get_contacts()) == 3 ac3_chat.send_text("Hi!") - msg_id = ac2.wait_for_incoming_msg_event().msg_id - message = ac2.get_message_by_id(msg_id) - snapshot = message.get_snapshot() - logging.info("Received message %s", snapshot.text) - assert snapshot.text == "Hi!" - ac1.wait_for_incoming_msg_event() # Hi! - ac3_contact_ac2 = ac3.get_contact_by_addr(ac2.get_config("addr")) - ac3_chat.remove_contact(ac3_contact_ac2) - - msg_id = ac2.wait_for_incoming_msg_event().msg_id - message = ac2.get_message_by_id(msg_id) - snapshot = message.get_snapshot() - assert "removed" in snapshot.text + ac3_contact_ac2 = ac3.create_contact(ac2) + ac3_chat.remove_contact(ac3_contact_ac2_old) snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot() assert "removed" in snapshot.text @@ -354,19 +280,16 @@ def test_verified_group_member_added_recovery(acfactory) -> None: snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot() assert snapshot.text == "Works again!" - ac1_contact_ac2 = ac1.get_contact_by_addr(ac2.get_config("addr")) + ac1_contact_ac2 = ac1.create_contact(ac2) + ac1_contact_ac3 = ac1.create_contact(ac3) ac1_contact_ac2_snapshot = ac1_contact_ac2.get_snapshot() assert ac1_contact_ac2_snapshot.is_verified - assert ac1_contact_ac2_snapshot.verifier_id == ac1.get_contact_by_addr(ac3.get_config("addr")).id - - # ac2 is now verified by ac3 for ac1 - ac1_contact_ac3 = ac1.get_contact_by_addr(ac3.get_config("addr")) - assert ac1_contact_ac2.get_snapshot().verifier_id == ac1_contact_ac3.id + assert ac1_contact_ac2_snapshot.verifier_id == ac1_contact_ac3.id def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): """Regression test for - issue . + issue . """ ac1, ac2, ac3, ac4 = acfactory.get_online_accounts(4) @@ -400,12 +323,12 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): logging.info("ac2 now has pending bobstate but ac1 is shutoff") # we meanwhile expect ac3/ac2 verification started in the beginning to have completed - assert ac3.get_contact_by_addr(ac2.get_config("addr")).get_snapshot().is_verified - assert ac2.get_contact_by_addr(ac3.get_config("addr")).get_snapshot().is_verified + assert ac3.create_contact(ac2).get_snapshot().is_verified + assert ac2.create_contact(ac3).get_snapshot().is_verified logging.info("ac3: create a verified group VG with ac2") vg = ac3.create_group("ac3-created", protect=True) - vg.add_contact(ac3.get_contact_by_addr(ac2.get_config("addr"))) + vg.add_contact(ac3.create_contact(ac2)) # ensure ac2 receives message in VG vg.send_text("hello") @@ -443,7 +366,7 @@ def test_qr_new_group_unblocked(acfactory): ac1.wait_for_securejoin_inviter_success() ac1_new_chat = ac1.create_group("Another group") - ac1_new_chat.add_contact(ac1.get_contact_by_addr(ac2.get_config("addr"))) + ac1_new_chat.add_contact(ac1.create_contact(ac2)) # Receive "Member added" message. ac2.wait_for_incoming_msg_event() @@ -577,30 +500,8 @@ def test_securejoin_after_contact_resetup(acfactory) -> None: # ac1 resetups the account. ac1 = acfactory.resetup_account(ac1) - - # Loop sending message from ac1 to ac2 - # until ac2 accepts new ac1 key. - # - # This may not happen immediately because resetup of ac1 - # rewinds "smeared timestamp" so Date: header for messages - # sent by new ac1 are in the past compared to the last Date: - # header sent by old ac1. - while True: - # ac1 sends a message to ac2. - ac1_contact_ac2 = ac1.create_contact(ac2, "") - ac1_chat_ac2 = ac1_contact_ac2.create_chat() - ac1_chat_ac2.send_text("Hello!") - - # ac2 receives a message. - snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot() - assert snapshot.text == "Hello!" - logging.info("ac2 received Hello!") - - # ac1 is no longer verified for ac2 as new Autocrypt key is not the same as old verified key. - logging.info("ac2 addr={}, ac1 addr={}".format(ac2.get_config("addr"), ac1.get_config("addr"))) - if not ac2_contact_ac1.get_snapshot().is_verified: - break - time.sleep(1) + ac2_contact_ac1 = ac2.create_contact(ac1, "") + assert not ac2_contact_ac1.get_snapshot().is_verified # ac1 goes offline. ac1.remove() diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index d859140f7..b7b2b683c 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -170,7 +170,7 @@ def test_account(acfactory) -> None: assert alice.get_size() assert alice.is_configured() assert not alice.get_avatar() - assert alice.get_contact_by_addr(bob_addr) == alice_contact_bob + assert alice.get_contact_by_addr(bob_addr) is None # There is no address-contact, only key-contact assert alice.get_contacts() assert alice.get_contacts(snapshot=True) assert alice.self_contact @@ -287,7 +287,6 @@ def test_contact(acfactory) -> None: assert repr(alice_contact_bob) alice_contact_bob.block() alice_contact_bob.unblock() - alice_contact_bob.reset_encryption() alice_contact_bob.set_name("new name") alice_contact_bob.get_encryption_info() snapshot = alice_contact_bob.get_snapshot() diff --git a/python/examples/group_tracking.py b/python/examples/group_tracking.py deleted file mode 100644 index 51f63cb74..000000000 --- a/python/examples/group_tracking.py +++ /dev/null @@ -1,49 +0,0 @@ -# content of group_tracking.py - -from deltachat import account_hookimpl, run_cmdline - - -class GroupTrackingPlugin: - @account_hookimpl - def ac_incoming_message(self, message): - print("process_incoming message", message) - if message.text.strip() == "/quit": - message.account.shutdown() - else: - # unconditionally accept the chat - message.create_chat() - addr = message.get_sender_contact().addr - text = message.text - message.chat.send_text(f"echoing from {addr}:\n{text}") - - @account_hookimpl - def ac_outgoing_message(self, message): - print("ac_outgoing_message:", message) - - @account_hookimpl - def ac_configure_completed(self, success): - print("ac_configure_completed:", success) - - @account_hookimpl - def ac_chat_modified(self, chat): - print("ac_chat_modified:", chat.id, chat.get_name()) - for member in chat.get_contacts(): - print(f"chat member: {member.addr}") - - @account_hookimpl - def ac_member_added(self, chat, contact, actor, message): - print(f"ac_member_added {contact.addr} to chat {chat.id} from {actor or message.get_sender_contact().addr}") - for member in chat.get_contacts(): - print(f"chat member: {member.addr}") - - @account_hookimpl - def ac_member_removed(self, chat, contact, actor, message): - print(f"ac_member_removed {contact.addr} from chat {chat.id} by {actor or message.get_sender_contact().addr}") - - -def main(argv=None): - run_cmdline(argv=argv, account_plugins=[GroupTrackingPlugin()]) - - -if __name__ == "__main__": - main() diff --git a/python/examples/test_examples.py b/python/examples/test_examples.py index 7e55d6617..58fac9c65 100644 --- a/python/examples/test_examples.py +++ b/python/examples/test_examples.py @@ -1,10 +1,7 @@ import echo_and_quit -import group_tracking import py import pytest -from deltachat.events import FFIEventLogger - @pytest.fixture(scope="session") def datadir(): @@ -36,55 +33,3 @@ def test_echo_quit_plugin(acfactory, lp): lp.sec("send quit sequence") bot_chat.send_text("/quit") botproc.wait() - - -def test_group_tracking_plugin(acfactory, lp): - lp.sec("creating one group-tracking bot and two temp accounts") - botproc = acfactory.run_bot_process(group_tracking) - - ac1, ac2 = acfactory.get_online_accounts(2) - - ac1.add_account_plugin(FFIEventLogger(ac1)) - ac2.add_account_plugin(FFIEventLogger(ac2)) - - lp.sec("creating bot test group with bot") - bot_chat = ac1.qr_setup_contact(botproc.qr) - ac1._evtracker.wait_securejoin_joiner_progress(1000) - bot_contact = bot_chat.get_contacts()[0] - ch = ac1.create_group_chat("bot test group") - ch.add_contact(bot_contact) - ch.send_text("hello") - - botproc.fnmatch_lines( - """ - *ac_chat_modified*bot test group* - """, - ) - - lp.sec("adding third member {}".format(ac2.get_config("addr"))) - contact3 = ac1.create_contact(ac2) - ch.add_contact(contact3) - - reply = ac1._evtracker.wait_next_incoming_message() - assert "hello" in reply.text - - lp.sec("now looking at what the bot received") - botproc.fnmatch_lines( - """ - *ac_member_added {}*from*{}* - """.format( - contact3.addr, - ac1.get_config("addr"), - ), - ) - - lp.sec("contact successfully added, now removing") - ch.remove_contact(contact3) - botproc.fnmatch_lines( - """ - *ac_member_removed {}*from*{}* - """.format( - contact3.addr, - ac1.get_config("addr"), - ), - ) diff --git a/python/src/deltachat/account.py b/python/src/deltachat/account.py index 8bc199ce9..c40382db6 100644 --- a/python/src/deltachat/account.py +++ b/python/src/deltachat/account.py @@ -293,6 +293,8 @@ class Account: return Contact(self, contact_id) def get_contact(self, obj) -> Optional[Contact]: + if isinstance(obj, Account): + return self.create_contact(obj) if isinstance(obj, Contact): return obj (_, addr) = self.get_contact_addr_and_name(obj) diff --git a/python/src/deltachat/chat.py b/python/src/deltachat/chat.py index b5cfa9235..edc2d5ba7 100644 --- a/python/src/deltachat/chat.py +++ b/python/src/deltachat/chat.py @@ -417,7 +417,13 @@ class Chat: :raises ValueError: if contact could not be added :returns: None """ - contact = self.account.create_contact(obj) + from .contact import Contact + + if isinstance(obj, Contact): + contact = obj + else: + contact = self.account.create_contact(obj) + ret = lib.dc_add_contact_to_chat(self.account._dc_context, self.id, contact.id) if ret != 1: raise ValueError(f"could not add contact {contact!r} to chat") diff --git a/python/src/deltachat/events.py b/python/src/deltachat/events.py index b9fcb4f64..f559e9fad 100644 --- a/python/src/deltachat/events.py +++ b/python/src/deltachat/events.py @@ -13,7 +13,6 @@ from .account import Account from .capi import ffi, lib from .cutil import from_optional_dc_charpointer from .hookspec import account_hookimpl -from .message import map_system_message def get_dc_event_name(integer, _DC_EVENTNAME_MAP={}): @@ -304,21 +303,15 @@ class EventThread(threading.Thread): elif name == "DC_EVENT_INCOMING_MSG": msg = account.get_message_by_id(ffi_event.data2) if msg is not None: - yield map_system_message(msg) or ("ac_incoming_message", {"message": msg}) + yield ("ac_incoming_message", {"message": msg}) elif name == "DC_EVENT_MSGS_CHANGED": if ffi_event.data2 != 0: msg = account.get_message_by_id(ffi_event.data2) if msg is not None: if msg.is_outgoing(): - res = map_system_message(msg) - if res and res[0].startswith("ac_member"): - yield res yield "ac_outgoing_message", {"message": msg} elif msg.is_in_fresh(): - yield map_system_message(msg) or ( - "ac_incoming_message", - {"message": msg}, - ) + yield "ac_incoming_message", {"message": msg} elif name == "DC_EVENT_REACTIONS_CHANGED": assert ffi_event.data1 > 0 msg = account.get_message_by_id(ffi_event.data2) diff --git a/python/src/deltachat/message.py b/python/src/deltachat/message.py index d5447d76c..d15c1696e 100644 --- a/python/src/deltachat/message.py +++ b/python/src/deltachat/message.py @@ -2,7 +2,6 @@ import json import os -import re from datetime import datetime, timezone from typing import Optional, Union @@ -504,56 +503,3 @@ def get_viewtype_code_from_name(view_type_name): raise ValueError( f"message typecode not found for {view_type_name!r}, available {list(_view_type_mapping.keys())!r}", ) - - -# -# some helper code for turning system messages into hook events -# - - -def map_system_message(msg): - if msg.is_system_message(): - res = parse_system_add_remove(msg.text) - if not res: - return None - action, affected, actor = res - affected = msg.account.get_contact_by_addr(affected) - actor = None if actor == "me" else msg.account.get_contact_by_addr(actor) - d = {"chat": msg.chat, "contact": affected, "actor": actor, "message": msg} - return "ac_member_" + res[0], d - - -def extract_addr(text): - m = re.match(r".*\((.+@.+)\)", text) - if m: - text = m.group(1) - text = text.rstrip(".") - return text.strip() - - -def parse_system_add_remove(text): - """return add/remove info from parsing the given system message text. - - returns a (action, affected, actor) triple - """ - # You removed member a@b. - # You added member a@b. - # Member Me (x@y) removed by a@b. - # Member x@y added by a@b - # Member With space (tmp1@x.org) removed by tmp2@x.org. - # Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).", - # Group left by some one (tmp1@x.org). - # Group left by tmp1@x.org. - text = text.lower() - m = re.match(r"member (.+) (removed|added) by (.+)", text) - if m: - affected, action, actor = m.groups() - return action, extract_addr(affected), extract_addr(actor) - m = re.match(r"you (removed|added) member (.+)", text) - if m: - action, affected = m.groups() - return action, extract_addr(affected), "me" - if text.startswith("group left by "): - addr = extract_addr(text[13:]) - if addr: - return "removed", addr, addr diff --git a/python/tests/test_0_complex_or_slow.py b/python/tests/test_0_complex_or_slow.py index a61515187..86bb8d7f7 100644 --- a/python/tests/test_0_complex_or_slow.py +++ b/python/tests/test_0_complex_or_slow.py @@ -187,83 +187,6 @@ def test_qr_verified_group_and_chatting(acfactory, lp): assert msg.is_encrypted() -def test_undecipherable_group(acfactory, lp): - """Test how group messages that cannot be decrypted are - handled. - - Group name is encrypted and plaintext subject is set to "..." in - this case, so we should assign the messages to existing chat - instead of creating a new one. Since there is no existing group - chat, the messages should be assigned to 1-1 chat with the sender - of the message. - """ - - lp.sec("creating and configuring three accounts") - ac1, ac2, ac3 = acfactory.get_online_accounts(3) - - acfactory.introduce_each_other([ac1, ac2, ac3]) - - lp.sec("ac3 reinstalls DC and generates a new key") - ac3.stop_io() - acfactory.remove_preconfigured_keys() - ac4 = acfactory.new_online_configuring_account(cloned_from=ac3) - acfactory.wait_configured(ac4) - # Create contacts to make sure incoming messages are not treated as contact requests - chat41 = ac4.create_chat(ac1) - chat42 = ac4.create_chat(ac2) - ac4.start_io() - ac4._evtracker.wait_idle_inbox_ready() - - lp.sec("ac1: creating group chat with 2 other members") - chat = ac1.create_group_chat("title", contacts=[ac2, ac3]) - - lp.sec("ac1: send message to new group chat") - msg = chat.send_text("hello") - - lp.sec("ac2: checking that the chat arrived correctly") - msg = ac2._evtracker.wait_next_incoming_message() - assert msg.text == "hello" - assert msg.is_encrypted(), "Message is not encrypted" - - # ac4 cannot decrypt the message. - # Error message should be assigned to the chat with ac1. - lp.sec("ac4: checking that message is assigned to the sender chat") - error_msg = ac4._evtracker.wait_next_incoming_message() - assert error_msg.error # There is an error decrypting the message - assert error_msg.chat == chat41 - - lp.sec("ac2: sending a reply to the chat") - msg.chat.send_text("reply") - reply = ac1._evtracker.wait_next_incoming_message() - assert reply.text == "reply" - assert reply.is_encrypted(), "Reply is not encrypted" - - lp.sec("ac4: checking that reply is assigned to ac2 chat") - error_reply = ac4._evtracker.wait_next_incoming_message() - assert error_reply.error # There is an error decrypting the message - assert error_reply.chat == chat42 - - # Test that ac4 replies to error messages don't appear in the - # group chat on ac1 and ac2. - lp.sec("ac4: replying to ac1 and ac2") - - # Otherwise reply becomes a contact request. - chat41.send_text("I can't decrypt your message, ac1!") - chat42.send_text("I can't decrypt your message, ac2!") - - msg = ac1._evtracker.wait_next_incoming_message() - assert msg.error is None - assert msg.text == "I can't decrypt your message, ac1!" - assert msg.is_encrypted(), "Message is not encrypted" - assert msg.chat == ac1.create_chat(ac3) - - msg = ac2._evtracker.wait_next_incoming_message() - assert msg.error is None - assert msg.text == "I can't decrypt your message, ac2!" - assert msg.is_encrypted(), "Message is not encrypted" - assert msg.chat == ac2.create_chat(ac4) - - def test_ephemeral_timer(acfactory, lp): ac1, ac2 = acfactory.get_online_accounts(2) diff --git a/python/tests/test_1_online.py b/python/tests/test_1_online.py index e75f36a86..2a29a54fa 100644 --- a/python/tests/test_1_online.py +++ b/python/tests/test_1_online.py @@ -767,7 +767,7 @@ def test_mdn_asymmetric(acfactory, lp): assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1 -def test_send_and_receive_will_encrypt_decrypt(acfactory, lp): +def test_send_receive_encrypt(acfactory, lp): ac1, ac2 = acfactory.get_online_accounts(2) ac1.get_device_chat().mark_noticed() @@ -798,12 +798,11 @@ def test_send_and_receive_will_encrypt_decrypt(acfactory, lp): msg3.mark_seen() assert not list(ac1.get_fresh_messages()) - lp.sec("create group chat with two members, one of which has no encrypt state") + lp.sec("create group chat with two members") chat = ac1.create_group_chat("encryption test") chat.add_contact(ac2) - chat.add_contact(ac1.create_contact("notexisting@testrun.org")) msg = chat.send_text("test not encrypt") - assert not msg.is_encrypted() + assert msg.is_encrypted() ac1._evtracker.get_matching("DC_EVENT_SMTP_MESSAGE_SENT") @@ -1139,9 +1138,9 @@ def test_import_export_online_all(acfactory, tmp_path, data, lp): lp.sec("create some chat content") some1_addr = some1.get_config("addr") - chat1 = ac1.create_contact(some1_addr, name="some1").create_chat() + chat1 = ac1.create_contact(some1).create_chat() chat1.send_text("msg1") - assert len(ac1.get_contacts(query="some1")) == 1 + assert len(ac1.get_contacts()) == 1 original_image_path = data.get_path("d.png") chat1.send_image(original_image_path) @@ -1153,7 +1152,7 @@ def test_import_export_online_all(acfactory, tmp_path, data, lp): chat1.send_file(str(path)) def assert_account_is_proper(ac): - contacts = ac.get_contacts(query="some1") + contacts = ac.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] assert contact2.addr == some1_addr @@ -1288,79 +1287,6 @@ def test_set_get_contact_avatar(acfactory, data, lp): assert msg6.get_sender_contact().get_profile_image() is None -def test_add_remove_member_remote_events(acfactory, lp): - ac1, ac2, ac3 = acfactory.get_online_accounts(3) - ac1_addr = ac1.get_config("addr") - ac3_addr = ac3.get_config("addr") - # activate local plugin for ac2 - in_list = queue.Queue() - - class EventHolder: - def __init__(self, **kwargs) -> None: - self.__dict__.update(kwargs) - - class InPlugin: - @account_hookimpl - def ac_incoming_message(self, message): - # we immediately accept the sender because - # otherwise we won't see member_added contacts - message.create_chat() - - @account_hookimpl - def ac_chat_modified(self, chat): - in_list.put(EventHolder(action="chat-modified", chat=chat)) - - @account_hookimpl - def ac_member_added(self, chat, contact, message): - in_list.put(EventHolder(action="added", chat=chat, contact=contact, message=message)) - - @account_hookimpl - def ac_member_removed(self, chat, contact, message): - in_list.put(EventHolder(action="removed", chat=chat, contact=contact, message=message)) - - ac2.add_account_plugin(InPlugin()) - - lp.sec("ac1: create group chat with ac2") - chat = ac1.create_group_chat("hello", contacts=[ac2]) - - lp.sec("ac1: send a message to group chat to promote the group") - chat.send_text("afterwards promoted") - ev = in_list.get() - assert ev.action == "chat-modified" - assert chat.is_promoted() - assert sorted(x.addr for x in chat.get_contacts()) == sorted(x.addr for x in ev.chat.get_contacts()) - - lp.sec("ac1: add address2") - # note that if the above create_chat() would not - # happen we would not receive a proper member_added event - contact2 = chat.add_contact(ac3) - ev = in_list.get() - assert ev.action == "chat-modified" - ev = in_list.get() - assert ev.action == "chat-modified" - ev = in_list.get() - assert ev.action == "added" - assert ev.message.get_sender_contact().addr == ac1_addr - assert ev.contact.addr == ac3_addr - - lp.sec("ac1: remove address2") - chat.remove_contact(contact2) - ev = in_list.get() - assert ev.action == "chat-modified" - ev = in_list.get() - assert ev.action == "removed" - assert ev.contact.addr == contact2.addr - assert ev.message.get_sender_contact().addr == ac1_addr - - lp.sec("ac1: remove ac2 contact from chat") - chat.remove_contact(ac2) - ev = in_list.get() - assert ev.action == "chat-modified" - ev = in_list.get() - assert ev.action == "removed" - assert ev.message.get_sender_contact().addr == ac1_addr - - def test_system_group_msg_from_blocked_user(acfactory, lp): """ Tests that a blocked user removes you from a group. @@ -1760,44 +1686,6 @@ def test_configure_error_msgs_invalid_server(acfactory): assert "configuration" not in ev.data2.lower() -def test_name_changes(acfactory): - ac1, ac2 = acfactory.get_online_accounts(2) - ac1.set_config("displayname", "Account 1") - - chat12 = acfactory.get_accepted_chat(ac1, ac2) - contact = None - - def update_name(): - """Send a message from ac1 to ac2 to update the name""" - nonlocal contact - chat12.send_text("Hello") - msg = ac2._evtracker.wait_next_incoming_message() - contact = msg.get_sender_contact() - return contact.name - - assert update_name() == "Account 1" - - ac1.set_config("displayname", "Account 1 revision 2") - assert update_name() == "Account 1 revision 2" - - # Explicitly rename contact on ac2 to "Renamed" - ac2.create_contact(contact, name="Renamed") - assert contact.name == "Renamed" - ev = ac2._evtracker.get_matching("DC_EVENT_CONTACTS_CHANGED") - assert ev.data1 == contact.id - - # ac1 also renames itself into "Renamed" - assert update_name() == "Renamed" - ac1.set_config("displayname", "Renamed") - assert update_name() == "Renamed" - - # Contact name was set to "Renamed" explicitly before, - # so it should not be changed. - ac1.set_config("displayname", "Renamed again") - updated_name = update_name() - assert updated_name == "Renamed" - - def test_status(acfactory): """Test that status is transferred over the network.""" ac1, ac2 = acfactory.get_online_accounts(2) diff --git a/python/tests/test_3_offline.py b/python/tests/test_3_offline.py index aae958216..6a0a5ac40 100644 --- a/python/tests/test_3_offline.py +++ b/python/tests/test_3_offline.py @@ -1,51 +1,11 @@ import os -import time from datetime import datetime, timedelta, timezone import pytest import deltachat as dc from deltachat.tracker import ImexFailed -from deltachat import Account, account_hookimpl, Message - - -@pytest.mark.parametrize( - ("msgtext", "res"), - [ - ( - "Member Me (tmp1@x.org) removed by tmp2@x.org.", - ("removed", "tmp1@x.org", "tmp2@x.org"), - ), - ( - "Member With space (tmp1@x.org) removed by tmp2@x.org.", - ("removed", "tmp1@x.org", "tmp2@x.org"), - ), - ( - "Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).", - ("removed", "tmp1@x.org", "tmp2@x.org"), - ), - ( - "Member With space (tmp1@x.org) removed by me", - ("removed", "tmp1@x.org", "me"), - ), - ( - "Group left by some one (tmp1@x.org).", - ("removed", "tmp1@x.org", "tmp1@x.org"), - ), - ("Group left by tmp1@x.org.", ("removed", "tmp1@x.org", "tmp1@x.org")), - ( - "Member tmp1@x.org added by tmp2@x.org.", - ("added", "tmp1@x.org", "tmp2@x.org"), - ), - ("Member nothing bla bla", None), - ("Another unknown system message", None), - ], -) -def test_parse_system_add_remove(msgtext, res): - from deltachat.message import parse_system_add_remove - - out = parse_system_add_remove(msgtext) - assert out == res +from deltachat import Account, Message class TestOfflineAccountBasic: @@ -177,14 +137,15 @@ class TestOfflineContact: def test_get_contacts_and_delete(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) contacts = ac1.get_contacts() assert len(contacts) == 1 - assert contact1 in contacts assert not ac1.get_contacts(query="some2") - assert ac1.get_contacts(query="some1") + assert not ac1.get_contacts(query="some1") assert len(ac1.get_contacts(with_self=True)) == 2 + assert contact1 in ac1.get_contacts() assert ac1.delete_contact(contact1) assert contact1 not in ac1.get_contacts() @@ -199,9 +160,9 @@ class TestOfflineContact: def test_create_chat_flexibility(self, acfactory): ac1 = acfactory.get_pseudo_configured_account() ac2 = acfactory.get_pseudo_configured_account() - chat1 = ac1.create_chat(ac2) - chat2 = ac1.create_chat(ac2.get_self_contact().addr) - assert chat1 == chat2 + chat1 = ac1.create_chat(ac2) # This creates a key-contact chat + chat2 = ac1.create_chat(ac2.get_self_contact().addr) # This creates address-contact chat + assert chat1 != chat2 ac3 = acfactory.get_unconfigured_account() with pytest.raises(ValueError): ac1.create_chat(ac3) @@ -259,17 +220,18 @@ class TestOfflineChat: ac1 = acfactory.get_pseudo_configured_account() ac2 = acfactory.get_pseudo_configured_account() chat = ac1.create_group_chat(name="title1") - with pytest.raises(ValueError): - chat.add_contact(ac2.get_self_contact()) contact = chat.add_contact(ac2) assert contact.addr == ac2.get_config("addr") assert contact.name == ac2.get_config("displayname") assert contact.account == ac1 chat.remove_contact(ac2) - def test_group_chat_creation(self, ac1): - contact1 = ac1.create_contact("some1@example.org", name="some1") - contact2 = ac1.create_contact("some2@example.org", name="some2") + def test_group_chat_creation(self, acfactory): + ac1 = acfactory.get_pseudo_configured_account() + ac2 = acfactory.get_pseudo_configured_account() + ac3 = acfactory.get_pseudo_configured_account() + contact1 = ac1.create_contact(ac2) + contact2 = ac1.create_contact(ac3) chat = ac1.create_group_chat(name="title1", contacts=[contact1, contact2]) assert chat.get_name() == "title1" assert contact1 in chat.get_contacts() @@ -316,13 +278,14 @@ class TestOfflineChat: qr = chat.get_join_qr() assert ac2.check_qr(qr).is_ask_verifygroup - def test_removing_blocked_user_from_group(self, ac1, lp): + def test_removing_blocked_user_from_group(self, ac1, acfactory, lp): """ Test that blocked contact is not unblocked when removed from a group. See https://github.com/deltachat/deltachat-core-rust/issues/2030 """ lp.sec("Create a group chat with a contact") - contact = ac1.create_contact("some1@example.org") + ac2 = acfactory.get_pseudo_configured_account() + contact = ac1.create_contact(ac2) group = ac1.create_group_chat("title", contacts=[contact]) group.send_text("First group message") @@ -334,10 +297,6 @@ class TestOfflineChat: group.remove_contact(contact) assert contact.is_blocked() - lp.sec("ac1 adding blocked contact unblocks it") - group.add_contact(contact) - assert not contact.is_blocked() - def test_get_set_profile_image_simple(self, ac1, data): chat = ac1.create_group_chat(name="title1") p = data.get_path("d.png") @@ -480,7 +439,8 @@ class TestOfflineChat: backupdir = tmp_path / "backup" backupdir.mkdir() ac1 = acfactory.get_pseudo_configured_account() - chat = ac1.create_contact("some1 ").create_chat() + ac_contact = acfactory.get_pseudo_configured_account() + chat = ac1.create_contact(ac_contact).create_chat() # send a text message msg = chat.send_text("msg1") # send a binary file @@ -495,10 +455,10 @@ class TestOfflineChat: assert os.path.exists(path) ac2 = acfactory.get_unconfigured_account() ac2.import_all(path) - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + assert contact2.addr == ac_contact.get_config("addr") chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -511,8 +471,9 @@ class TestOfflineChat: backupdir = tmp_path / "backup" backupdir.mkdir() ac1 = acfactory.get_pseudo_configured_account(passphrase=passphrase1) + ac2 = acfactory.get_pseudo_configured_account() - chat = ac1.create_contact("some1 ").create_chat() + chat = ac1.create_contact(ac2).create_chat() # send a text message msg = chat.send_text("msg1") # send a binary file @@ -533,10 +494,10 @@ class TestOfflineChat: ac2.import_all(path) # check data integrity - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + contact2_addr = contact2.addr chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -550,10 +511,10 @@ class TestOfflineChat: ac2.open(passphrase2) # check data integrity - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + assert contact2.addr == contact2_addr chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -566,8 +527,9 @@ class TestOfflineChat: backupdir = tmp_path / "backup" backupdir.mkdir() ac1 = acfactory.get_pseudo_configured_account() + ac_contact = acfactory.get_pseudo_configured_account() - chat = ac1.create_contact("some1 ").create_chat() + chat = ac1.create_contact(ac_contact).create_chat() # send a text message msg = chat.send_text("msg1") # send a binary file @@ -589,10 +551,10 @@ class TestOfflineChat: ac2.import_all(path, passphrase) # check data integrity - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + assert contact2.addr == ac_contact.get_config("addr") chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -611,7 +573,8 @@ class TestOfflineChat: backupdir.mkdir() ac1 = acfactory.get_pseudo_configured_account() - chat = ac1.create_contact("some1 ").create_chat() + ac_contact = acfactory.get_pseudo_configured_account() + chat = ac1.create_contact(ac_contact).create_chat() # send a text message msg = chat.send_text("msg1") # send a binary file @@ -634,10 +597,10 @@ class TestOfflineChat: ac2.import_all(path, bak_passphrase) # check data integrity - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + assert contact2.addr == ac_contact.get_config("addr") chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -651,10 +614,10 @@ class TestOfflineChat: ac2.open(acct_passphrase) # check data integrity - contacts = ac2.get_contacts(query="some1") + contacts = ac2.get_contacts() assert len(contacts) == 1 contact2 = contacts[0] - assert contact2.addr == "some1@example.org" + assert contact2.addr == ac_contact.get_config("addr") chat2 = contact2.create_chat() messages = chat2.get_messages() assert len(messages) == 2 @@ -681,78 +644,10 @@ class TestOfflineChat: assert not res.is_ask_verifygroup() assert res.contact_id == 10 - def test_group_chat_many_members_add_remove(self, ac1, lp): - lp.sec("ac1: creating group chat with 10 other members") - chat = ac1.create_group_chat(name="title1") - # promote chat - chat.send_text("hello") - assert chat.is_promoted() + def test_audit_log_view_without_daymarker(self, acfactory, lp): + ac1 = acfactory.get_pseudo_configured_account() + ac2 = acfactory.get_pseudo_configured_account() - # activate local plugin - in_list = [] - - class InPlugin: - @account_hookimpl - def ac_member_added(self, chat, contact, actor): - in_list.append(("added", chat, contact, actor)) - - @account_hookimpl - def ac_member_removed(self, chat, contact, actor): - in_list.append(("removed", chat, contact, actor)) - - ac1.add_account_plugin(InPlugin()) - - # perform add contact many times - contacts = [] - for i in range(10): - lp.sec("create contact") - contact = ac1.create_contact(f"some{i}@example.org") - contacts.append(contact) - lp.sec("add contact") - chat.add_contact(contact) - - assert chat.num_contacts() == 11 - - # let's make sure the events perform plugin hooks - def wait_events(cond): - now = time.time() - while time.time() < now + 5: - if cond(): - break - time.sleep(0.1) - else: - pytest.fail("failed to get events") - - wait_events(lambda: len(in_list) == 10) - - assert len(in_list) == 10 - chat_contacts = chat.get_contacts() - for in_cmd, in_chat, in_contact, in_actor in in_list: - assert in_cmd == "added" - assert in_chat == chat - assert in_contact in chat_contacts - assert in_actor is None - chat_contacts.remove(in_contact) - - assert chat_contacts[0].id == 1 # self contact - - in_list[:] = [] - - lp.sec("ac1: removing two contacts and checking things are right") - chat.remove_contact(contacts[9]) - chat.remove_contact(contacts[3]) - assert chat.num_contacts() == 9 - - wait_events(lambda: len(in_list) == 2) - assert len(in_list) == 2 - assert in_list[0][0] == "removed" - assert in_list[0][1] == chat - assert in_list[0][2] == contacts[9] - assert in_list[1][0] == "removed" - assert in_list[1][1] == chat - assert in_list[1][2] == contacts[3] - - def test_audit_log_view_without_daymarker(self, ac1, lp): lp.sec("ac1: test audit log (show only system messages)") chat = ac1.create_group_chat(name="audit log sample data") @@ -761,7 +656,7 @@ class TestOfflineChat: assert chat.is_promoted() lp.sec("create test data") - chat.add_contact(ac1.create_contact("some-1@example.org")) + chat.add_contact(ac2) chat.set_name("audit log test group") chat.send_text("a message in between") diff --git a/src/authres.rs b/src/authres.rs index 21879b0f4..0ac928434 100644 --- a/src/authres.rs +++ b/src/authres.rs @@ -266,7 +266,6 @@ mod tests { use super::*; use crate::mimeparser; - use crate::peerstate::Peerstate; use crate::test_utils::TestContext; use crate::test_utils::TestContextManager; use crate::tools; @@ -520,41 +519,6 @@ Authentication-Results: dkim="; handle_authres(&t, &mail, "invalid@rom.com").await.unwrap(); } - // Test that Autocrypt works with mailing list. - // - // Previous versions of Delta Chat ignored Autocrypt based on the List-Post header. - // This is not needed: comparing of the From address to Autocrypt header address is enough. - // If the mailing list is not rewriting the From header, Autocrypt should be applied. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_autocrypt_in_mailinglist_not_ignored() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - let alice_bob_chat = alice.create_chat(&bob).await; - let bob_alice_chat = bob.create_chat(&alice).await; - let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await; - sent.payload - .insert_str(0, "List-Post: \n"); - bob.recv_msg(&sent).await; - let peerstate = Peerstate::from_addr(&bob, "alice@example.org").await?; - assert!(peerstate.is_some()); - - // Bob can now write encrypted to Alice: - let mut sent = bob - .send_text(bob_alice_chat.id, "hellooo in the mailinglist again") - .await; - assert!(sent.load_from_db().await.get_showpadlock()); - - sent.payload - .insert_str(0, "List-Post: \n"); - let rcvd = alice.recv_msg(&sent).await; - assert!(rcvd.get_showpadlock()); - assert_eq!(&rcvd.text, "hellooo in the mailinglist again"); - - Ok(()) - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_authres_in_mailinglist_ignored() -> Result<()> { let mut tcm = TestContextManager::new(); diff --git a/src/chat.rs b/src/chat.rs index d79a76ec8..d8ed7073c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -15,17 +15,14 @@ use deltachat_derive::{FromSql, ToSql}; use mail_builder::mime::MimePart; use serde::{Deserialize, Serialize}; use strum_macros::EnumIter; -use tokio::task; -use crate::aheader::EncryptPreference; use crate::blob::BlobObject; use crate::chatlist::Chatlist; use crate::color::str_to_color; use crate::config::Config; use crate::constants::{ - self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK, - DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX, - TIMESTAMP_SENT_TOLERANCE, + Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK, DC_CHAT_ID_LAST_SPECIAL, + DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX, TIMESTAMP_SENT_TOLERANCE, }; use crate::contact::{self, Contact, ContactId, Origin}; use crate::context::Context; @@ -39,7 +36,6 @@ use crate::message::{self, Message, MessageState, MsgId, Viewtype}; use crate::mimefactory::MimeFactory; use crate::mimeparser::SystemMessage; use crate::param::{Param, Params}; -use crate::peerstate::Peerstate; use crate::receive_imf::ReceivedMsg; use crate::smtp::send_msg_to_smtp; use crate::stock_str; @@ -130,8 +126,8 @@ pub(crate) enum CantSendReason { /// Not a member of the chat. NotAMember, - /// Temporary state for 1:1 chats while SecureJoin is in progress. - SecurejoinWait, + /// State for 1:1 chat with a key-contact that does not have a key. + MissingKey, } impl fmt::Display for CantSendReason { @@ -151,7 +147,7 @@ impl fmt::Display for CantSendReason { write!(f, "mailing list does not have a know post address") } Self::NotAMember => write!(f, "not a member of the chat"), - Self::SecurejoinWait => write!(f, "awaiting SecureJoin for 1:1 chat"), + Self::MissingKey => write!(f, "key is missing"), } } } @@ -1326,8 +1322,12 @@ impl ChatId { /// /// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`]. pub async fn get_encryption_info(self, context: &Context) -> Result { - let mut ret_available = String::new(); - let mut ret_reset = String::new(); + let chat = Chat::load_from_db(context, self).await?; + if !chat.is_encrypted(context).await? { + return Ok(stock_str::encr_none(context).await); + } + + let mut ret = stock_str::e2e_available(context).await + "\n"; for contact_id in get_chat_contacts(context, self) .await? @@ -1336,34 +1336,15 @@ impl ChatId { { let contact = Contact::get_by_id(context, *contact_id).await?; let addr = contact.get_addr(); - let peerstate = Peerstate::from_addr(context, addr).await?; - - match peerstate - .filter(|peerstate| peerstate.peek_key(false).is_some()) - .map(|peerstate| peerstate.prefer_encrypt) - { - Some(EncryptPreference::Mutual) | Some(EncryptPreference::NoPreference) => { - ret_available += &format!("{addr}\n") - } - Some(EncryptPreference::Reset) | None => ret_reset += &format!("{addr}\n"), - }; - } - - let mut ret = String::new(); - if !ret_reset.is_empty() { - ret += &stock_str::encr_none(context).await; - ret.push(':'); - ret.push('\n'); - ret += &ret_reset; - } - if !ret_available.is_empty() { - if !ret.is_empty() { - ret.push('\n'); + debug_assert!(contact.is_key_contact()); + let fingerprint = contact + .fingerprint() + .context("Contact does not have a fingerprint in encrypted chat")?; + if contact.public_key(context).await?.is_some() { + ret += &format!("\n{addr}\n{fingerprint}\n"); + } else { + ret += &format!("\n{addr}\n(key missing)\n{fingerprint}\n"); } - ret += &stock_str::e2e_available(context).await; - ret.push(':'); - ret.push('\n'); - ret += &ret_available; } Ok(ret.trim().to_string()) @@ -1473,18 +1454,6 @@ impl ChatId { Ok(sort_timestamp) } - - /// Spawns a task checking after a timeout whether the SecureJoin has finished for the 1:1 chat - /// and otherwise notifying the user accordingly. - pub(crate) fn spawn_securejoin_wait(self, context: &Context, timeout: u64) { - let context = context.clone(); - task::spawn(async move { - tokio::time::sleep(Duration::from_secs(timeout)).await; - let chat = Chat::load_from_db(&context, self).await?; - chat.check_securejoin_wait(&context, 0).await?; - Result::<()>::Ok(()) - }); - } } impl std::fmt::Display for ChatId { @@ -1696,15 +1665,18 @@ impl Chat { if !skip_fn(&reason) && !self.is_self_in_chat(context).await? { return Ok(Some(reason)); } - let reason = SecurejoinWait; - if !skip_fn(&reason) - && self - .check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT) - .await? - > 0 - { - return Ok(Some(reason)); + + let reason = MissingKey; + if !skip_fn(&reason) && self.typ == Chattype::Single { + let contact_ids = get_chat_contacts(context, self.id).await?; + if let Some(contact_id) = contact_ids.first() { + let contact = Contact::get_by_id(context, *contact_id).await?; + if contact.is_key_contact() && contact.public_key(context).await?.is_none() { + return Ok(Some(reason)); + } + } } + Ok(None) } @@ -1715,74 +1687,6 @@ impl Chat { Ok(self.why_cant_send(context).await?.is_none()) } - /// Returns the remaining timeout for the 1:1 chat in-progress SecureJoin. - /// - /// If the timeout has expired, adds an info message with additional information. - /// See also [`CantSendReason::SecurejoinWait`]. - pub(crate) async fn check_securejoin_wait( - &self, - context: &Context, - timeout: u64, - ) -> Result { - if self.typ != Chattype::Single || self.protected != ProtectionStatus::Unprotected { - return Ok(0); - } - - // chat is single and unprotected: - // get last info message of type SecurejoinWait or SecurejoinWaitTimeout - let (mut param_wait, mut param_timeout) = (Params::new(), Params::new()); - param_wait.set_cmd(SystemMessage::SecurejoinWait); - param_timeout.set_cmd(SystemMessage::SecurejoinWaitTimeout); - let (param_wait, param_timeout) = (param_wait.to_string(), param_timeout.to_string()); - let Some((param, ts_sort, ts_start)) = context - .sql - .query_row_optional( - "SELECT param, timestamp, timestamp_sent FROM msgs WHERE id=\ - (SELECT MAX(id) FROM msgs WHERE chat_id=? AND param IN (?, ?))", - (self.id, ¶m_wait, ¶m_timeout), - |row| { - let param: String = row.get(0)?; - let ts_sort: i64 = row.get(1)?; - let ts_start: i64 = row.get(2)?; - Ok((param, ts_sort, ts_start)) - }, - ) - .await? - else { - return Ok(0); - }; - if param == param_timeout { - return Ok(0); - } - - let now = time(); - // Don't await SecureJoin if the clock was set back. - if ts_start <= now { - let timeout = ts_start - .saturating_add(timeout.try_into()?) - .saturating_sub(now); - if timeout > 0 { - return Ok(timeout as u64); - } - } - add_info_msg_with_cmd( - context, - self.id, - &stock_str::securejoin_takes_longer(context).await, - SystemMessage::SecurejoinWaitTimeout, - // Use the sort timestamp of the "please wait" message, this way the added message is - // never sorted below the protection message if the SecureJoin finishes in parallel. - ts_sort, - Some(now), - None, - None, - None, - ) - .await?; - context.emit_event(EventType::ChatModified(self.id)); - Ok(0) - } - /// Checks if the user is part of a chat /// and has basically the permissions to edit the chat therefore. /// The function does not check if the chat type allows editing of concrete elements. @@ -1826,25 +1730,36 @@ impl Chat { /// Returns profile image path for the chat. pub async fn get_profile_image(&self, context: &Context) -> Result> { - if let Some(image_rel) = self.param.get(Param::ProfileImage) { + if self.id.is_archived_link() { + // This is not a real chat, but the "Archive" button + // that is shown at the top of the chats list + return Ok(Some(get_archive_icon(context).await?)); + } else if self.is_device_talk() { + return Ok(Some(get_device_icon(context).await?)); + } else if self.is_self_talk() { + return Ok(Some(get_saved_messages_icon(context).await?)); + } else if self.typ == Chattype::Single { + // For 1:1 chats, we always use the same avatar as for the contact + // This is before the `self.is_encrypted()` check, because that function + // has two database calls, i.e. it's slow + let contacts = get_chat_contacts(context, self.id).await?; + if let Some(contact_id) = contacts.first() { + let contact = Contact::get_by_id(context, *contact_id).await?; + return contact.get_profile_image(context).await; + } + } else if !self.is_encrypted(context).await? { + // This is an address-contact chat, show a special avatar that marks it as such + return Ok(Some(get_abs_path( + context, + Path::new(&get_address_contact_icon(context).await?), + ))); + } else if let Some(image_rel) = self.param.get(Param::ProfileImage) { + // Load the group avatar, or the device-chat / saved-messages icon if !image_rel.is_empty() { return Ok(Some(get_abs_path(context, Path::new(&image_rel)))); } - } else if self.id.is_archived_link() { - if let Ok(image_rel) = get_archive_icon(context).await { - return Ok(Some(get_abs_path(context, Path::new(&image_rel)))); - } - } else if self.typ == Chattype::Single { - let contacts = get_chat_contacts(context, self.id).await?; - if let Some(contact_id) = contacts.first() { - if let Ok(contact) = Contact::get_by_id(context, *contact_id).await { - return contact.get_profile_image(context).await; - } - } } else if self.typ == Chattype::Broadcast { - if let Ok(image_rel) = get_broadcast_icon(context).await { - return Ok(Some(get_abs_path(context, Path::new(&image_rel)))); - } + return Ok(Some(get_broadcast_icon(context).await?)); } Ok(None) } @@ -1935,6 +1850,33 @@ impl Chat { self.protected == ProtectionStatus::Protected } + /// Returns true if the chat is encrypted. + pub async fn is_encrypted(&self, context: &Context) -> Result { + let is_encrypted = self.is_protected() + || match self.typ { + Chattype::Single => { + let chat_contact_ids = get_chat_contacts(context, self.id).await?; + if let Some(contact_id) = chat_contact_ids.first() { + if *contact_id == ContactId::DEVICE { + true + } else { + let contact = Contact::get_by_id(context, *contact_id).await?; + contact.is_key_contact() + } + } else { + true + } + } + Chattype::Group => { + // Do not encrypt ad-hoc groups. + !self.grpid.is_empty() + } + Chattype::Mailinglist => false, + Chattype::Broadcast => true, + }; + Ok(is_encrypted) + } + /// 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, @@ -2287,19 +2229,41 @@ impl Chat { /// Sends a `SyncAction` synchronising chat contacts to other devices. pub(crate) async fn sync_contacts(&self, context: &Context) -> Result<()> { - let addrs = context - .sql - .query_map( - "SELECT c.addr \ - FROM contacts c INNER JOIN chats_contacts cc \ - ON c.id=cc.contact_id \ - WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp", - (self.id,), - |row| row.get::<_, String>(0), - |addrs| addrs.collect::, _>>().map_err(Into::into), - ) - .await?; - self.sync(context, SyncAction::SetContacts(addrs)).await + if self.is_encrypted(context).await? { + let fingerprint_addrs = context + .sql + .query_map( + "SELECT c.fingerprint, c.addr + FROM contacts c INNER JOIN chats_contacts cc + ON c.id=cc.contact_id + WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp", + (self.id,), + |row| { + let fingerprint = row.get(0)?; + let addr = row.get(1)?; + Ok((fingerprint, addr)) + }, + |addrs| addrs.collect::, _>>().map_err(Into::into), + ) + .await?; + self.sync(context, SyncAction::SetPgpContacts(fingerprint_addrs)) + .await?; + } else { + let addrs = context + .sql + .query_map( + "SELECT c.addr \ + FROM contacts c INNER JOIN chats_contacts cc \ + ON c.id=cc.contact_id \ + WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp", + (self.id,), + |row| row.get::<_, String>(0), + |addrs| addrs.collect::, _>>().map_err(Into::into), + ) + .await?; + self.sync(context, SyncAction::SetContacts(addrs)).await?; + } + Ok(()) } /// Returns chat id for the purpose of synchronisation across devices. @@ -2319,7 +2283,11 @@ impl Chat { return Ok(None); } let contact = Contact::get_by_id(context, contact_id).await?; - r = Some(SyncId::ContactAddr(contact.get_addr().to_string())); + if let Some(fingerprint) = contact.fingerprint() { + r = Some(SyncId::ContactFingerprint(fingerprint.hex())); + } else { + r = Some(SyncId::ContactAddr(contact.get_addr().to_string())); + } } Ok(r) } @@ -2465,69 +2433,63 @@ pub struct ChatInfo { // - [ ] email } -pub(crate) async fn update_saved_messages_icon(context: &Context) -> Result<()> { - if let Some(ChatIdBlocked { id: chat_id, .. }) = - ChatIdBlocked::lookup_by_contact(context, ContactId::SELF).await? - { - let icon = include_bytes!("../assets/icon-saved-messages.png"); - let blob = - BlobObject::create_and_deduplicate_from_bytes(context, icon, "saved-messages.png")?; - let icon = blob.as_name().to_string(); - - let mut chat = Chat::load_from_db(context, chat_id).await?; - chat.param.set(Param::ProfileImage, icon); - chat.update_param(context).await?; - } - Ok(()) -} - -pub(crate) async fn update_device_icon(context: &Context) -> Result<()> { - if let Some(ChatIdBlocked { id: chat_id, .. }) = - ChatIdBlocked::lookup_by_contact(context, ContactId::DEVICE).await? - { - let icon = include_bytes!("../assets/icon-device.png"); - let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "device.png")?; - let icon = blob.as_name().to_string(); - - let mut chat = Chat::load_from_db(context, chat_id).await?; - chat.param.set(Param::ProfileImage, &icon); - chat.update_param(context).await?; - - let mut contact = Contact::get_by_id(context, ContactId::DEVICE).await?; - contact.param.set(Param::ProfileImage, icon); - contact.update_param(context).await?; - } - Ok(()) -} - -pub(crate) async fn get_broadcast_icon(context: &Context) -> Result { - if let Some(icon) = context.sql.get_raw_config("icon-broadcast").await? { - return Ok(icon); +async fn get_asset_icon(context: &Context, name: &str, bytes: &[u8]) -> Result { + ensure!(name.starts_with("icon-")); + if let Some(icon) = context.sql.get_raw_config(name).await? { + return Ok(get_abs_path(context, Path::new(&icon))); } - let icon = include_bytes!("../assets/icon-broadcast.png"); - let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "broadcast.png")?; + let blob = + BlobObject::create_and_deduplicate_from_bytes(context, bytes, &format!("{name}.png"))?; let icon = blob.as_name().to_string(); - context - .sql - .set_raw_config("icon-broadcast", Some(&icon)) - .await?; - Ok(icon) + context.sql.set_raw_config(name, Some(&icon)).await?; + + Ok(get_abs_path(context, Path::new(&icon))) } -pub(crate) async fn get_archive_icon(context: &Context) -> Result { - if let Some(icon) = context.sql.get_raw_config("icon-archive").await? { - return Ok(icon); - } +pub(crate) async fn get_saved_messages_icon(context: &Context) -> Result { + get_asset_icon( + context, + "icon-saved-messages", + include_bytes!("../assets/icon-saved-messages.png"), + ) + .await +} - let icon = include_bytes!("../assets/icon-archive.png"); - let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "archive.png")?; - let icon = blob.as_name().to_string(); - context - .sql - .set_raw_config("icon-archive", Some(&icon)) - .await?; - Ok(icon) +pub(crate) async fn get_device_icon(context: &Context) -> Result { + get_asset_icon( + context, + "icon-device", + include_bytes!("../assets/icon-device.png"), + ) + .await +} + +pub(crate) async fn get_broadcast_icon(context: &Context) -> Result { + get_asset_icon( + context, + "icon-broadcast", + include_bytes!("../assets/icon-broadcast.png"), + ) + .await +} + +pub(crate) async fn get_archive_icon(context: &Context) -> Result { + get_asset_icon( + context, + "icon-archive", + include_bytes!("../assets/icon-archive.png"), + ) + .await +} + +pub(crate) async fn get_address_contact_icon(context: &Context) -> Result { + get_asset_icon( + context, + "icon-address-contact", + include_bytes!("../assets/icon-address-contact.png"), + ) + .await } async fn update_special_chat_name( @@ -2566,34 +2528,6 @@ pub(crate) async fn update_special_chat_names(context: &Context) -> Result<()> { Ok(()) } -/// Checks if there is a 1:1 chat in-progress SecureJoin for Bob and, if necessary, schedules a task -/// unblocking the chat and notifying the user accordingly. -pub(crate) async fn resume_securejoin_wait(context: &Context) -> Result<()> { - let chat_ids: Vec = context - .sql - .query_map( - "SELECT chat_id FROM bobstate", - (), - |row| { - let chat_id: ChatId = row.get(0)?; - Ok(chat_id) - }, - |rows| rows.collect::, _>>().map_err(Into::into), - ) - .await?; - - for chat_id in chat_ids { - let chat = Chat::load_from_db(context, chat_id).await?; - let timeout = chat - .check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT) - .await?; - if timeout > 0 { - chat_id.spawn_securejoin_wait(context, timeout); - } - } - Ok(()) -} - /// Handle a [`ChatId`] and its [`Blocked`] status at once. /// /// This struct is an optimisation to read a [`ChatId`] and its [`Blocked`] status at once @@ -2677,12 +2611,7 @@ impl ChatIdBlocked { _ => (), } - let protected = contact_id == ContactId::SELF || { - let peerstate = Peerstate::from_addr(context, contact.get_addr()).await?; - peerstate.is_some_and(|p| { - p.is_using_verified_key() && p.prefer_encrypt == EncryptPreference::Mutual - }) - }; + let protected = contact_id == ContactId::SELF || contact.is_verified(context).await?; let smeared_time = create_smeared_timestamp(context); let chat_id = context @@ -2734,12 +2663,6 @@ impl ChatIdBlocked { .await?; } - match contact_id { - ContactId::SELF => update_saved_messages_icon(context).await?, - ContactId::DEVICE => update_device_icon(context).await?, - _ => (), - } - Ok(Self { id: chat_id, blocked: create_blocked, @@ -2919,9 +2842,7 @@ async fn prepare_send_msg( let mut chat = Chat::load_from_db(context, chat_id).await?; let skip_fn = |reason: &CantSendReason| match reason { - CantSendReason::ProtectionBroken - | CantSendReason::ContactRequest - | CantSendReason::SecurejoinWait => { + CantSendReason::ProtectionBroken | CantSendReason::ContactRequest => { // Allow securejoin messages, they are supposed to repair the verification. // If the chat is a contact request, let the user accept it later. msg.param.get_cmd() == SystemMessage::SecurejoinMessage @@ -2930,6 +2851,10 @@ async fn prepare_send_msg( // Necessary checks should be made anyway before removing contact // from the chat. CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup, + CantSendReason::MissingKey => msg + .param + .get_bool(Param::ForcePlaintext) + .unwrap_or_default(), _ => false, }; if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? { @@ -3898,6 +3823,10 @@ pub(crate) async fn add_contact_to_chat_ex( chat.typ != Chattype::Broadcast || contact_id != ContactId::SELF, "Cannot add SELF to broadcast." ); + ensure!( + chat.is_encrypted(context).await? == contact.is_key_contact(), + "Only key-contacts can be added to encrypted chats" + ); if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( @@ -3949,7 +3878,7 @@ pub(crate) async fn add_contact_to_chat_ex( msg.viewtype = Viewtype::Text; let contact_addr = contact.get_addr().to_lowercase(); - msg.text = stock_str::msg_add_member_local(context, &contact_addr, ContactId::SELF).await; + msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await; msg.param.set_cmd(SystemMessage::MemberAddedToGroup); msg.param.set(Param::Arg, contact_addr); msg.param.set_int(Param::Arg2, from_handshake.into()); @@ -4143,12 +4072,9 @@ pub async fn remove_contact_from_chat( if contact_id == ContactId::SELF { msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await; } else { - msg.text = stock_str::msg_del_member_local( - context, - contact.get_addr(), - ContactId::SELF, - ) - .await; + msg.text = + stock_str::msg_del_member_local(context, contact_id, ContactId::SELF) + .await; } msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup); msg.param.set(Param::Arg, contact.get_addr().to_lowercase()); @@ -4283,8 +4209,12 @@ pub async fn set_chat_profile_image( ensure!(!chat_id.is_special(), "Invalid chat ID"); let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group || chat.typ == Chattype::Mailinglist, - "Failed to set profile image; group does not exist" + chat.typ == Chattype::Group, + "Can only set profile image for group chats" + ); + ensure!( + !chat.grpid.is_empty(), + "Cannot set profile image for ad hoc groups" ); /* we should respect this - whatever we send to the group, it gets discarded anyway! */ if !is_contact_in_chat(context, chat_id, ContactId::SELF).await? { @@ -4849,6 +4779,10 @@ pub(crate) async fn update_msg_text_and_timestamp( /// Set chat contacts by their addresses creating the corresponding contacts if necessary. async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String]) -> Result<()> { let chat = Chat::load_from_db(context, id).await?; + ensure!( + !chat.is_encrypted(context).await?, + "Cannot add address-contacts to encrypted chat {id}" + ); ensure!( chat.typ == Chattype::Broadcast, "{id} is not a broadcast list", @@ -4884,10 +4818,64 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String]) Ok(()) } +/// Set chat contacts by their fingerprints creating the corresponding contacts if necessary. +/// +/// `fingerprint_addrs` is a list of pairs of fingerprint and address. +async fn set_contacts_by_fingerprints( + context: &Context, + id: ChatId, + fingerprint_addrs: &[(String, String)], +) -> Result<()> { + let chat = Chat::load_from_db(context, id).await?; + ensure!( + chat.is_encrypted(context).await?, + "Cannot add key-contacts to unencrypted chat {id}" + ); + ensure!( + chat.typ == Chattype::Broadcast, + "{id} is not a broadcast list", + ); + let mut contacts = HashSet::new(); + for (fingerprint, addr) in fingerprint_addrs { + let contact_addr = ContactAddress::new(addr)?; + let contact = + Contact::add_or_lookup_ex(context, "", &contact_addr, fingerprint, Origin::Hidden) + .await? + .0; + contacts.insert(contact); + } + let contacts_old = HashSet::::from_iter(get_chat_contacts(context, id).await?); + if contacts == contacts_old { + return Ok(()); + } + context + .sql + .transaction(move |transaction| { + transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?; + + // We do not care about `add_timestamp` column + // because timestamps are not used for broadcast lists. + let mut statement = transaction + .prepare("INSERT INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)")?; + for contact_id in &contacts { + statement.execute((id, contact_id))?; + } + Ok(()) + }) + .await?; + context.emit_event(EventType::ChatModified(id)); + Ok(()) +} + /// A cross-device chat id used for synchronisation. #[derive(Debug, Serialize, Deserialize, PartialEq)] pub(crate) enum SyncId { + /// E-mail address of the contact. ContactAddr(String), + + /// OpenPGP key fingerprint of the contact. + ContactFingerprint(String), + Grpid(String), /// "Message-ID"-s, from oldest to latest. Used for ad-hoc groups. Msgids(Vec), @@ -4909,6 +4897,10 @@ pub(crate) enum SyncAction { Rename(String), /// Set chat contacts by their addresses. SetContacts(Vec), + /// Set chat contacts by their fingerprints. + /// + /// The list is a list of pairs of fingerprint and address. + SetPgpContacts(Vec<(String, String)>), Delete, } @@ -4939,6 +4931,30 @@ impl Context { .await? .id } + SyncId::ContactFingerprint(fingerprint) => { + let name = ""; + let addr = ""; + let (contact_id, _) = + Contact::add_or_lookup_ex(self, name, addr, fingerprint, Origin::Hidden) + .await?; + match action { + SyncAction::Rename(to) => { + contact_id.set_name_ex(self, Nosync, to).await?; + self.emit_event(EventType::ContactsChanged(Some(contact_id))); + return Ok(()); + } + SyncAction::Block => { + return contact::set_blocked(self, Nosync, contact_id, true).await + } + SyncAction::Unblock => { + return contact::set_blocked(self, Nosync, contact_id, false).await + } + _ => (), + } + ChatIdBlocked::get_for_contact(self, contact_id, Blocked::Request) + .await? + .id + } SyncId::Grpid(grpid) => { if let SyncAction::CreateBroadcast(name) = action { create_broadcast_list_ex(self, Nosync, grpid.clone(), name.clone()).await?; @@ -4969,6 +4985,9 @@ impl Context { } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, SyncAction::SetContacts(addrs) => set_contacts_by_addrs(self, chat_id, addrs).await, + SyncAction::SetPgpContacts(fingerprint_addrs) => { + set_contacts_by_fingerprints(self, chat_id, fingerprint_addrs).await + } SyncAction::Delete => chat_id.delete_ex(self, Nosync).await, } } diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 908ac29af..6cf5c0368 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -7,6 +7,7 @@ use crate::imex::{has_backup, imex, ImexMode}; use crate::message::{delete_msgs, MessengerMessage}; use crate::receive_imf::receive_imf; use crate::test_utils::{sync, TestContext, TestContextManager, TimeShiftFalsePositiveNote}; +use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use tokio::fs; @@ -19,31 +20,39 @@ async fn test_chat_info() { // Ensure we can serialize this. println!("{}", serde_json::to_string_pretty(&info).unwrap()); - let expected = r#" - { - "id": 10, - "type": 100, - "name": "bob", - "archived": false, - "param": "", - "gossiped_timestamp": 0, - "is_sending_locations": false, - "color": 35391, - "profile_image": "", - "draft": "", - "is_muted": false, - "ephemeral_timer": "Disabled" - } - "#; + let expected = format!( + r#"{{ + "id": 10, + "type": 100, + "name": "bob", + "archived": false, + "param": "", + "is_sending_locations": false, + "color": 35391, + "profile_image": {}, + "draft": "", + "is_muted": false, + "ephemeral_timer": "Disabled" +}}"#, + // We need to do it like this so that the test passes on Windows: + serde_json::to_string( + t.get_blobdir() + .join("4138c52e5bc1c576cda7dd44d088c07.png") + .to_str() + .unwrap() + ) + .unwrap() + ); // Ensure we can deserialize this. - let loaded: ChatInfo = serde_json::from_str(expected).unwrap(); - assert_eq!(info, loaded); + serde_json::from_str::(&expected).unwrap(); + + assert_eq!(serde_json::to_string_pretty(&info).unwrap(), expected); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_draft_no_draft() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; let chat = t.get_self_chat().await; let draft = chat.id.get_draft(&t).await.unwrap(); assert!(draft.is_none()); @@ -60,14 +69,14 @@ async fn test_get_draft_special_chat_id() { async fn test_get_draft_no_chat() { // This is a weird case, maybe this should be an error but we // do not get this info from the database currently. - let t = TestContext::new().await; + let t = TestContext::new_alice().await; let draft = ChatId::new(42).get_draft(&t).await.unwrap(); assert!(draft.is_none()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_draft() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; let chat_id = &t.get_self_chat().await.id; let mut msg = Message::new_text("hello".to_string()); @@ -320,18 +329,20 @@ async fn test_member_add_remove() -> Result<()> { assert_eq!(alice_bob_contact.get_display_name(), "robert"); } - // Create and promote a group. + tcm.section("Create and promote a group."); let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?; - alice + let sent = alice .send_text(alice_chat_id, "Hi! I created a group.") .await; + let fiona_chat_id = fiona.recv_msg(&sent).await.chat_id; - // Alice adds Bob to the chat. + tcm.section("Alice adds Bob to the chat."); add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; let sent = alice.pop_sent_msg().await; + fiona.recv_msg(&sent).await; // Locally set name "robert" should not leak. assert!(!sent.payload.contains("robert")); @@ -339,8 +350,15 @@ async fn test_member_add_remove() -> Result<()> { sent.load_from_db().await.get_text(), "You added member robert." ); + let fiona_contact_ids = get_chat_contacts(&fiona, fiona_chat_id).await?; + assert_eq!(fiona_contact_ids.len(), 3); + for contact_id in fiona_contact_ids { + let contact = Contact::get_by_id(&fiona, contact_id).await?; + assert_ne!(contact.get_name(), "robert"); + assert!(contact.is_key_contact()); + } - // Alice removes Bob from the chat. + tcm.section("Alice removes Bob from the chat."); remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; let sent = alice.pop_sent_msg().await; assert!(!sent.payload.contains("robert")); @@ -371,7 +389,7 @@ async fn test_parallel_member_remove() -> Result<()> { let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; let alice_charlie_contact_id = alice.add_or_lookup_contact_id(&charlie).await; - // Create and promote a group. + tcm.section("Alice creates and promotes a group"); let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?; add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; @@ -384,31 +402,31 @@ async fn test_parallel_member_remove() -> Result<()> { let bob_chat_id = bob_received_msg.get_chat_id(); bob_chat_id.accept(&bob).await?; - // Alice adds Charlie to the chat. + tcm.section("Alice adds Charlie to the chat"); add_contact_to_chat(&alice, alice_chat_id, alice_charlie_contact_id).await?; let alice_sent_add_msg = alice.pop_sent_msg().await; - // Bob leaves the chat. + tcm.section("Bob leaves the chat"); remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?; bob.pop_sent_msg().await; - // Bob receives a msg about Alice adding Claire to the group. + tcm.section("Bob receives a message about Alice adding Charlie to the group"); bob.recv_msg(&alice_sent_add_msg).await; SystemTime::shift(Duration::from_secs(3600)); - // Alice sends a message to Bob because the message about leaving is lost. + tcm.section("Alice sends a message to Bob because the message about leaving is lost"); let alice_sent_msg = alice.send_text(alice_chat_id, "What a silence!").await; bob.recv_msg(&alice_sent_msg).await; bob.golden_test_chat(bob_chat_id, "chat_test_parallel_member_remove") .await; - // Alice removes Bob from the chat. + tcm.section("Alice removes Bob from the chat"); remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; let alice_sent_remove_msg = alice.pop_sent_msg().await; - // Bob receives a msg about Alice removing him from the group. + tcm.section("Bob receives a msg about Alice removing him from the group"); let bob_received_remove_msg = bob.recv_msg(&alice_sent_remove_msg).await; // Test that remove message is rewritten. @@ -621,6 +639,7 @@ async fn test_lost_member_added() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; let alice_chat_id = alice .create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob]) .await; @@ -629,8 +648,8 @@ async fn test_lost_member_added() -> Result<()> { assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 2); // Attempt to add member, but message is lost. - let claire_id = Contact::create(alice, "", "claire@foo.de").await?; - add_contact_to_chat(alice, alice_chat_id, claire_id).await?; + let charlie_id = alice.add_or_lookup_contact_id(charlie).await; + add_contact_to_chat(alice, alice_chat_id, charlie_id).await?; alice.pop_sent_msg().await; let alice_sent = alice.send_text(alice_chat_id, "Hi again!").await; @@ -693,12 +712,12 @@ async fn test_leave_group() -> Result<()> { let alice = tcm.alice().await; let bob = tcm.bob().await; - // Create group chat with Bob. + tcm.section("Alice creates group chat with Bob."); let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; let bob_contact = alice.add_or_lookup_contact(&bob).await.id; add_contact_to_chat(&alice, alice_chat_id, bob_contact).await?; - // Alice sends first message to group. + tcm.section("Alice sends first message to group."); let sent_msg = alice.send_text(alice_chat_id, "Hello!").await; let bob_msg = bob.recv_msg(&sent_msg).await; @@ -711,7 +730,7 @@ async fn test_leave_group() -> Result<()> { // Shift the time so that we can later check the 'Group left' message's timestamp: SystemTime::shift(Duration::from_secs(60)); - // Bob leaves the group. + tcm.section("Bob leaves the group."); let bob_chat_id = bob_msg.chat_id; bob_chat_id.accept(&bob).await?; remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?; @@ -897,7 +916,11 @@ async fn test_add_device_msg_labelled() -> Result<()> { assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat)); assert_eq!(chat.name, stock_str::device_messages(&t).await); - assert!(chat.get_profile_image(&t).await?.is_some()); + let device_msg_icon = chat.get_profile_image(&t).await?.unwrap(); + assert_eq!( + device_msg_icon.metadata()?.len(), + include_bytes!("../../assets/icon-device.png").len() as u64 + ); // delete device message, make sure it is not added again message::delete_msgs(&t, &[*msg1_id.as_ref().unwrap()]).await?; @@ -968,7 +991,7 @@ async fn test_delete_device_chat() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_device_chat_cannot_sent() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; t.update_device_chats().await.unwrap(); let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE) .await @@ -981,6 +1004,18 @@ async fn test_device_chat_cannot_sent() { assert!(forward_msgs(&t, &[msg_id], device_chat_id).await.is_err()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_device_chat_is_encrypted() { + let t = TestContext::new_alice().await; + t.update_device_chats().await.unwrap(); + let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE) + .await + .unwrap(); + + let device_chat = Chat::load_from_db(&t, device_chat_id).await.unwrap(); + assert!(device_chat.is_encrypted(&t).await.unwrap()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_and_reset_all_device_msgs() { let t = TestContext::new().await; @@ -1015,7 +1050,7 @@ async fn chatlist_len(ctx: &Context, listflags: usize) -> usize { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_archive() { // create two chats - let t = TestContext::new().await; + let t = TestContext::new_alice().await; let mut msg = Message::new_text("foo".to_string()); let msg_id = add_device_msg(&t, None, Some(&mut msg)).await.unwrap(); let chat_id1 = message::Message::load_from_db(&t, msg_id) @@ -1313,7 +1348,7 @@ async fn get_chats_from_chat_list(ctx: &Context, listflags: usize) -> Vec Result<()> { - let t = TestContext::new().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; - assert!(!shall_attach_selfavatar(&t, chat_id).await?); + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; - let (contact_id, _) = Contact::add_or_lookup( - &t, - "", - &ContactAddress::new("foo@bar.org")?, - Origin::IncomingUnknownTo, - ) - .await?; - add_contact_to_chat(&t, chat_id, contact_id).await?; - assert!(shall_attach_selfavatar(&t, chat_id).await?); + let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?; + assert!(!shall_attach_selfavatar(alice, chat_id).await?); - chat_id.set_selfavatar_timestamp(&t, time()).await?; - assert!(!shall_attach_selfavatar(&t, chat_id).await?); + let contact_id = alice.add_or_lookup_contact_id(bob).await; + add_contact_to_chat(alice, chat_id, contact_id).await?; + assert!(shall_attach_selfavatar(alice, chat_id).await?); - t.set_config(Config::Selfavatar, None).await?; // setting to None also forces re-sending - assert!(shall_attach_selfavatar(&t, chat_id).await?); + chat_id.set_selfavatar_timestamp(alice, time()).await?; + assert!(!shall_attach_selfavatar(alice, chat_id).await?); + + alice.set_config(Config::Selfavatar, None).await?; // setting to None also forces re-sending + assert!(shall_attach_selfavatar(alice, chat_id).await?); Ok(()) } @@ -1509,15 +1541,10 @@ async fn test_shall_attach_selfavatar() -> Result<()> { async fn test_profile_data_on_group_leave() -> Result<()> { let mut tcm = TestContextManager::new(); let t = &tcm.alice().await; + let bob = &tcm.bob().await; let chat_id = create_group_chat(t, ProtectionStatus::Unprotected, "foo").await?; - let (contact_id, _) = Contact::add_or_lookup( - t, - "", - &ContactAddress::new("foo@bar.org")?, - Origin::IncomingUnknownTo, - ) - .await?; + let contact_id = t.add_or_lookup_contact_id(bob).await; add_contact_to_chat(t, chat_id, contact_id).await?; send_text_msg(t, chat_id, "populate".to_string()).await?; @@ -1532,7 +1559,8 @@ async fn test_profile_data_on_group_leave() -> Result<()> { remove_contact_from_chat(t, chat_id, ContactId::SELF).await?; let sent_msg = t.pop_sent_msg().await; - assert!(sent_msg.payload().contains("Chat-User-Avatar")); + let msg = bob.parse_msg(&sent_msg).await; + assert!(msg.header_exists(HeaderDef::ChatUserAvatar)); Ok(()) } @@ -2183,40 +2211,46 @@ async fn test_forward_group() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_only_minimal_data_are_forwarded() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let charlie = tcm.charlie().await; + // send a message from Alice to a group with Bob - let alice = TestContext::new_alice().await; alice .set_config(Config::Displayname, Some("secretname")) .await?; - let bob_id = Contact::create(&alice, "bob", "bob@example.net").await?; + let bob_id = alice.add_or_lookup_contact_id(&bob).await; let group_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "secretgrpname").await?; add_contact_to_chat(&alice, group_id, bob_id).await?; let mut msg = Message::new_text("bla foo".to_owned()); let sent_msg = alice.send_msg(group_id, &mut msg).await; - assert!(sent_msg.payload().contains("secretgrpname")); - assert!(sent_msg.payload().contains("secretname")); - assert!(sent_msg.payload().contains("alice")); + let parsed_msg = alice.parse_msg(&sent_msg).await; + let encrypted_payload = String::from_utf8(parsed_msg.decoded_data.clone()).unwrap(); + assert!(encrypted_payload.contains("secretgrpname")); + assert!(encrypted_payload.contains("secretname")); + assert!(encrypted_payload.contains("alice")); // Bob forwards that message to Claire - // Claire should not get information about Alice for the original Group - let bob = TestContext::new_bob().await; let orig_msg = bob.recv_msg(&sent_msg).await; - let claire_id = Contact::create(&bob, "claire", "claire@foo").await?; - let single_id = ChatId::create_for_contact(&bob, claire_id).await?; + let charlie_id = bob.add_or_lookup_contact_id(&charlie).await; + let single_id = ChatId::create_for_contact(&bob, charlie_id).await?; let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?; - add_contact_to_chat(&bob, group_id, claire_id).await?; + add_contact_to_chat(&bob, group_id, charlie_id).await?; let broadcast_id = create_broadcast_list(&bob).await?; - add_contact_to_chat(&bob, broadcast_id, claire_id).await?; + add_contact_to_chat(&bob, broadcast_id, charlie_id).await?; for chat_id in &[single_id, group_id, broadcast_id] { forward_msgs(&bob, &[orig_msg.id], *chat_id).await?; let sent_msg = bob.pop_sent_msg().await; - assert!(sent_msg - .payload() - .contains("---------- Forwarded message ----------")); - assert!(!sent_msg.payload().contains("secretgrpname")); - assert!(!sent_msg.payload().contains("secretname")); - assert!(!sent_msg.payload().contains("alice")); + let parsed_msg = bob.parse_msg(&sent_msg).await; + let encrypted_payload = String::from_utf8(parsed_msg.decoded_data.clone()).unwrap(); + + assert!(encrypted_payload.contains("---------- Forwarded message ----------")); + assert!(!encrypted_payload.contains("secretgrpname")); + assert!(!encrypted_payload.contains("secretname")); + assert!(!encrypted_payload.contains("alice")); } Ok(()) @@ -2500,43 +2534,6 @@ async fn test_resend_foreign_message_fails() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_resend_opportunistically_encryption() -> Result<()> { - // Alice creates group with Bob and sends an initial message - let alice = TestContext::new_alice().await; - let alice_grp = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?; - add_contact_to_chat( - &alice, - alice_grp, - Contact::create(&alice, "", "bob@example.net").await?, - ) - .await?; - let sent1 = alice.send_text(alice_grp, "alice->bob").await; - - // Bob now can send an encrypted message - let bob = TestContext::new_bob().await; - let msg = bob.recv_msg(&sent1).await; - assert!(!msg.get_showpadlock()); - - msg.chat_id.accept(&bob).await?; - let sent2 = bob.send_text(msg.chat_id, "bob->alice").await; - let msg = bob.get_last_msg().await; - assert!(msg.get_showpadlock()); - - // Bob adds Claire and resends his last message: this will drop encryption in opportunistic chats - add_contact_to_chat( - &bob, - msg.chat_id, - Contact::create(&bob, "", "claire@example.org").await?, - ) - .await?; - let _sent3 = bob.pop_sent_msg().await; - resend_msgs(&bob, &[sent2.sender_msg_id]).await?; - let _sent4 = bob.pop_sent_msg().await; - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_resend_info_message_fails() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -2737,53 +2734,64 @@ async fn test_create_for_contact_with_blocked() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chat_get_encryption_info() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let fiona = &tcm.fiona().await; - let contact_bob = Contact::create(&alice, "Bob", "bob@example.net").await?; - let contact_fiona = Contact::create(&alice, "", "fiona@example.net").await?; + let contact_bob = alice.add_or_lookup_contact_id(bob).await; + let contact_fiona = alice.add_or_lookup_contact_id(fiona).await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?; - assert_eq!(chat_id.get_encryption_info(&alice).await?, ""); - - add_contact_to_chat(&alice, chat_id, contact_bob).await?; + let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; assert_eq!( - chat_id.get_encryption_info(&alice).await?, - "No encryption:\n\ - bob@example.net" + chat_id.get_encryption_info(alice).await?, + "End-to-end encryption available" ); - add_contact_to_chat(&alice, chat_id, contact_fiona).await?; + add_contact_to_chat(alice, chat_id, contact_bob).await?; assert_eq!( - chat_id.get_encryption_info(&alice).await?, - "No encryption:\n\ - fiona@example.net\n\ - bob@example.net" + chat_id.get_encryption_info(alice).await?, + "End-to-end encryption available\n\ + \n\ + bob@example.net\n\ + CCCB 5AA9 F6E1 141C 9431\n\ + 65F1 DB18 B18C BCF7 0487" ); - let direct_chat = bob.create_chat(&alice).await; - send_text_msg(&bob, direct_chat.id, "Hello!".to_string()).await?; - alice.recv_msg(&bob.pop_sent_msg().await).await; - + add_contact_to_chat(alice, chat_id, contact_fiona).await?; assert_eq!( - chat_id.get_encryption_info(&alice).await?, - "No encryption:\n\ - fiona@example.net\n\ - \n\ - End-to-end encryption available:\n\ - bob@example.net" + chat_id.get_encryption_info(alice).await?, + "End-to-end encryption available\n\ + \n\ + fiona@example.net\n\ + C8BA 50BF 4AC1 2FAF 38D7\n\ + F657 DDFC 8E9F 3C79 9195\n\ + \n\ + bob@example.net\n\ + CCCB 5AA9 F6E1 141C 9431\n\ + 65F1 DB18 B18C BCF7 0487" ); - send_text_msg(&bob, direct_chat.id, "Hello!".to_string()).await?; - alice.recv_msg(&bob.pop_sent_msg().await).await; - + let email_chat = alice.create_email_chat(bob).await; assert_eq!( - chat_id.get_encryption_info(&alice).await?, - "No encryption:\n\ - fiona@example.net\n\ - \n\ - End-to-end encryption available:\n\ - bob@example.net" + email_chat.id.get_encryption_info(alice).await?, + "No encryption" + ); + + alice.sql.execute("DELETE FROM public_keys", ()).await?; + assert_eq!( + chat_id.get_encryption_info(alice).await?, + "End-to-end encryption available\n\ + \n\ + fiona@example.net\n\ + (key missing)\n\ + C8BA 50BF 4AC1 2FAF 38D7\n\ + F657 DDFC 8E9F 3C79 9195\n\ + \n\ + bob@example.net\n\ + (key missing)\n\ + CCCB 5AA9 F6E1 141C 9431\n\ + 65F1 DB18 B18C BCF7 0487" ); Ok(()) @@ -3039,40 +3047,41 @@ async fn test_blob_renaming() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sync_blocked() -> Result<()> { - let alice0 = &TestContext::new_alice().await; - let alice1 = &TestContext::new_alice().await; + let mut tcm = TestContextManager::new(); + let alice0 = &tcm.alice().await; + let alice1 = &tcm.alice().await; for a in [alice0, alice1] { a.set_config_bool(Config::SyncMsgs, true).await?; } - let bob = TestContext::new_bob().await; + let bob = &tcm.bob().await; let ba_chat = bob.create_chat(alice0).await; let sent_msg = bob.send_text(ba_chat.id, "hi").await; let a0b_chat_id = alice0.recv_msg(&sent_msg).await.chat_id; alice1.recv_msg(&sent_msg).await; - let a0b_contact_id = alice0.add_or_lookup_contact_id(&bob).await; + let a0b_contact_id = alice0.add_or_lookup_contact_id(bob).await; - assert_eq!(alice1.get_chat(&bob).await.blocked, Blocked::Request); + assert_eq!(alice1.get_chat(bob).await.blocked, Blocked::Request); a0b_chat_id.accept(alice0).await?; sync(alice0, alice1).await; - assert_eq!(alice1.get_chat(&bob).await.blocked, Blocked::Not); + assert_eq!(alice1.get_chat(bob).await.blocked, Blocked::Not); a0b_chat_id.block(alice0).await?; sync(alice0, alice1).await; - assert_eq!(alice1.get_chat(&bob).await.blocked, Blocked::Yes); + assert_eq!(alice1.get_chat(bob).await.blocked, Blocked::Yes); a0b_chat_id.unblock(alice0).await?; sync(alice0, alice1).await; - assert_eq!(alice1.get_chat(&bob).await.blocked, Blocked::Not); + assert_eq!(alice1.get_chat(bob).await.blocked, Blocked::Not); // Unblocking a 1:1 chat doesn't unblock the contact currently. Contact::unblock(alice0, a0b_contact_id).await?; - assert!(!alice1.add_or_lookup_contact(&bob).await.is_blocked()); + assert!(!alice1.add_or_lookup_contact(bob).await.is_blocked()); Contact::block(alice0, a0b_contact_id).await?; sync(alice0, alice1).await; - assert!(alice1.add_or_lookup_contact(&bob).await.is_blocked()); + assert!(alice1.add_or_lookup_contact(bob).await.is_blocked()); Contact::unblock(alice0, a0b_contact_id).await?; sync(alice0, alice1).await; - assert!(!alice1.add_or_lookup_contact(&bob).await.is_blocked()); + assert!(!alice1.add_or_lookup_contact(bob).await.is_blocked()); // Test accepting and blocking groups. This way we test: // - Group chats synchronisation. @@ -3132,7 +3141,7 @@ async fn test_sync_accept_before_first_msg() -> Result<()> { assert_eq!(alice1_contacts.len(), 1); let a1b_contact_id = alice1_contacts[0]; let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?; - assert_eq!(a1b_contact.get_addr(), "bob@example.net"); + assert_eq!(a1b_contact.get_addr(), ""); assert_eq!(a1b_contact.origin, Origin::CreateChat); let a1b_chat = alice1.get_chat(bob).await; assert_eq!(a1b_chat.blocked, Blocked::Not); @@ -3146,23 +3155,30 @@ async fn test_sync_accept_before_first_msg() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sync_block_before_first_msg() -> Result<()> { - let alice0 = &TestContext::new_alice().await; - let alice1 = &TestContext::new_alice().await; + let mut tcm = TestContextManager::new(); + let alice0 = &tcm.alice().await; + let alice1 = &tcm.alice().await; for a in [alice0, alice1] { a.set_config_bool(Config::SyncMsgs, true).await?; } - let bob = TestContext::new_bob().await; + let bob = &tcm.bob().await; let ba_chat = bob.create_chat(alice0).await; let sent_msg = bob.send_text(ba_chat.id, "hi").await; let rcvd_msg = alice0.recv_msg(&sent_msg).await; let a0b_chat_id = rcvd_msg.chat_id; let a0b_contact_id = rcvd_msg.from_id; - assert_eq!(alice0.get_chat(&bob).await.blocked, Blocked::Request); + assert_eq!( + Chat::load_from_db(alice0, a0b_chat_id).await?.blocked, + Blocked::Request + ); a0b_chat_id.block(alice0).await?; let a0b_contact = Contact::get_by_id(alice0, a0b_contact_id).await?; assert_eq!(a0b_contact.origin, Origin::IncomingUnknownFrom); - assert_eq!(alice0.get_chat(&bob).await.blocked, Blocked::Yes); + assert_eq!( + Chat::load_from_db(alice0, a0b_chat_id).await?.blocked, + Blocked::Yes + ); sync(alice0, alice1).await; let alice1_contacts = Contact::get_all(alice1, 0, None).await?; @@ -3172,9 +3188,14 @@ async fn test_sync_block_before_first_msg() -> Result<()> { let a1b_contact_id = rcvd_msg.from_id; let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?; assert_eq!(a1b_contact.origin, Origin::IncomingUnknownFrom); - let a1b_chat = alice1.get_chat(&bob).await; - assert_eq!(a1b_chat.blocked, Blocked::Yes); - assert_eq!(rcvd_msg.chat_id, a1b_chat.id); + let ChatIdBlocked { + id: a1b_chat_id, + blocked: a1b_chat_blocked, + } = ChatIdBlocked::lookup_by_contact(alice1, a1b_contact_id) + .await? + .unwrap(); + assert_eq!(a1b_chat_blocked, Blocked::Yes); + assert_eq!(rcvd_msg.chat_id, a1b_chat_id); Ok(()) } @@ -3366,8 +3387,8 @@ async fn test_sync_broadcast() -> Result<()> { for a in [alice0, alice1] { a.set_config_bool(Config::SyncMsgs, true).await?; } - let bob = TestContext::new_bob().await; - let a0b_contact_id = alice0.add_or_lookup_contact(&bob).await.id; + let bob = &tcm.bob().await; + let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id; let a0_broadcast_id = create_broadcast_list(alice0).await?; sync(alice0, alice1).await; @@ -3382,20 +3403,19 @@ async fn test_sync_broadcast() -> Result<()> { assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; sync(alice0, alice1).await; - let a1b_contact_id = Contact::lookup_id_by_addr( - alice1, - &bob.get_config(Config::Addr).await?.unwrap(), - Origin::Hidden, - ) - .await? - .unwrap(); + + // This also imports Bob's key from the vCard. + // Otherwise it is possible that second device + // does not have Bob's key as only the fingerprint + // is transferred in the sync message. + let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id; assert_eq!( get_chat_contacts(alice1, a1_broadcast_id).await?, vec![a1b_contact_id] ); let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; let msg = bob.recv_msg(&sent_msg).await; - let chat = Chat::load_from_db(&bob, msg.chat_id).await?; + let chat = Chat::load_from_db(bob, msg.chat_id).await?; assert_eq!(chat.get_type(), Chattype::Mailinglist); let msg = alice0.recv_msg(&sent_msg).await; assert_eq!(msg.chat_id, a0_broadcast_id); @@ -3514,7 +3534,7 @@ async fn test_info_contact_id() -> Result<()> { expected_bob_id: ContactId, ) -> Result<()> { let sent_msg = alice.pop_sent_msg().await; - let msg = Message::load_from_db(alice, sent_msg.sender_msg_id).await?; + let msg = sent_msg.load_from_db().await; assert_eq!(msg.get_info_type(), expected_type); assert_eq!( msg.get_info_contact_id(alice).await?, @@ -3677,19 +3697,24 @@ async fn test_past_members() -> Result<()> { let fiona = &tcm.fiona().await; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; + tcm.section("Alice creates a chat."); let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; alice .send_text(alice_chat_id, "Hi! I created a group.") .await; + + tcm.section("Alice removes Fiona from the chat."); remove_contact_from_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 1); + tcm.section("Alice adds Bob to the chat."); let bob = &tcm.bob().await; let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + tcm.section("Bob receives a message."); let add_message = alice.pop_sent_msg().await; let bob_add_message = bob.recv_msg(&add_message).await; let bob_chat_id = bob_add_message.chat_id; @@ -4212,5 +4237,108 @@ async fn test_oneone_gossip() -> Result<()> { assert_eq!(rcvd_msg2.get_showpadlock(), true); assert_eq!(rcvd_msg2.text, "Hello from second device!"); + tcm.section("Alice sends another message from the first devicer"); + let sent_msg3 = alice.send_text(alice_chat.id, "Hello again, Bob!").await; + + // This message has no Autocrypt-Gossip header, + // but should still be assigned to key-contact. + tcm.section("Alice receives a copy of another message on second device"); + let rcvd_msg3 = alice2.recv_msg(&sent_msg3).await; + assert_eq!(rcvd_msg3.get_showpadlock(), true); + assert_eq!(rcvd_msg3.chat_id, rcvd_msg.chat_id); + + // Check that there was no gossip. + let parsed_msg3 = alice2.parse_msg(&sent_msg3).await; + assert!(!parsed_msg3.header_exists(HeaderDef::AutocryptGossip)); + + Ok(()) +} + +/// Tests that address-contacts cannot be added to encrypted group chats. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_address_contacts_in_group_chats() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; + + let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await; + let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await; + + // key-contact should be added successfully. + add_contact_to_chat(alice, chat_id, bob_key_contact_id).await?; + + // Adding address-contact should fail. + let res = add_contact_to_chat(alice, chat_id, charlie_address_contact_id).await; + assert!(res.is_err()); + + Ok(()) +} + +/// Tests that key-contacts cannot be added to ad hoc groups. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_key_contacts_in_adhoc_chats() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; + + let chat_id = receive_imf( + alice, + b"Subject: Email thread\r\n\ + From: alice@example.org\r\n\ + To: Bob , Fiona \r\n\ + Date: Mon, 2 Dec 2023 16:59:39 +0000\r\n\ + Message-ID: \r\n\ + \r\n\ + Starting a new thread\r\n", + false, + ) + .await? + .unwrap() + .chat_id; + + let bob_address_contact_id = alice.add_or_lookup_address_contact_id(bob).await; + let charlie_key_contact_id = alice.add_or_lookup_contact_id(charlie).await; + + // Address-contact should be added successfully. + add_contact_to_chat(alice, chat_id, bob_address_contact_id).await?; + + // Adding key-contact should fail. + let res = add_contact_to_chat(alice, chat_id, charlie_key_contact_id).await; + assert!(res.is_err()); + + Ok(()) +} + +/// Tests that avatar cannot be set in ad hoc groups. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_avatar_in_adhoc_chats() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let chat_id = receive_imf( + alice, + b"Subject: Email thread\r\n\ + From: alice@example.org\r\n\ + To: Bob , Fiona \r\n\ + Date: Mon, 2 Dec 2023 16:59:39 +0000\r\n\ + Message-ID: \r\n\ + \r\n\ + Starting a new thread\r\n", + false, + ) + .await? + .unwrap() + .chat_id; + + // Test that setting avatar in ad hoc group is not possible. + let file = alice.dir.path().join("avatar.png"); + let bytes = include_bytes!("../../test-data/image/avatar64x64.png"); + tokio::fs::write(&file, bytes).await?; + let res = set_chat_profile_image(alice, chat_id, file.to_str().unwrap()).await; + assert!(res.is_err()); + Ok(()) } diff --git a/src/chatlist.rs b/src/chatlist.rs index d38a17784..5c36f1a90 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -572,7 +572,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sort_self_talk_up_on_forward() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; t.update_device_chats().await.unwrap(); create_group_chat(&t, ProtectionStatus::Unprotected, "a chat") .await @@ -605,7 +605,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_search_special_chat_names() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; t.update_device_chats().await.unwrap(); let chats = Chatlist::try_load(&t, 0, Some("t-1234-s"), None) diff --git a/src/constants.rs b/src/constants.rs index 522d67502..884b498dc 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -210,11 +210,6 @@ pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60; /// in the group membership consistency algo to reject outdated membership changes. pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60; -/// How long a 1:1 chat can't be used for sending while the SecureJoin is in progress. This should -/// be 10-20 seconds so that we are reasonably sure that the app remains active and receiving also -/// on mobile devices. See also [`crate::chat::CantSendReason::SecurejoinWait`]. -pub(crate) const SECUREJOIN_WAIT_TIMEOUT: u64 = 15; - // To make text edits clearer for Non-Delta-MUA or old Delta Chats, edited text will be prefixed by EDITED_PREFIX. // Newer Delta Chats will remove the prefix as needed. pub(crate) const EDITED_PREFIX: &str = "✏️"; diff --git a/src/contact.rs b/src/contact.rs index cc6976c2e..e702f7b2a 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -1,6 +1,6 @@ //! Contacts module -use std::cmp::{min, Reverse}; +use std::cmp::Reverse; use std::collections::{BinaryHeap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; @@ -11,8 +11,8 @@ use async_channel::{self as channel, Receiver, Sender}; use base64::Engine as _; pub use deltachat_contact_tools::may_be_valid_addr; use deltachat_contact_tools::{ - self as contact_tools, addr_cmp, addr_normalize, sanitize_name, sanitize_name_and_addr, - ContactAddress, VcardContact, + self as contact_tools, addr_normalize, sanitize_name, sanitize_name_and_addr, ContactAddress, + VcardContact, }; use deltachat_derive::{FromSql, ToSql}; use rusqlite::OptionalExtension; @@ -20,7 +20,6 @@ use serde::{Deserialize, Serialize}; use tokio::task; use tokio::time::{timeout, Duration}; -use crate::aheader::{Aheader, EncryptPreference}; use crate::blob::BlobObject; use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus}; use crate::color::str_to_color; @@ -28,14 +27,16 @@ use crate::config::Config; use crate::constants::{Blocked, Chattype, DC_GCL_ADD_SELF}; use crate::context::Context; use crate::events::EventType; -use crate::key::{load_self_public_key, DcKey, SignedPublicKey}; +use crate::key::{ + load_self_public_key, self_fingerprint, self_fingerprint_opt, DcKey, Fingerprint, + SignedPublicKey, +}; use crate::log::{info, warn, LogExt}; use crate::message::MessageState; use crate::mimeparser::AvatarAction; use crate::param::{Param, Params}; -use crate::peerstate::Peerstate; use crate::sync::{self, Sync::*}; -use crate::tools::{duration_to_str, get_abs_path, smeared_time, time, SystemTime}; +use crate::tools::{duration_to_str, get_abs_path, time, SystemTime}; use crate::{chat, chatlist_events, stock_str}; /// Time during which a contact is considered as seen recently. @@ -102,7 +103,16 @@ impl ContactId { /// for this contact will switch to the /// contact's authorized name. pub async fn set_name(self, context: &Context, name: &str) -> Result<()> { - let addr = context + self.set_name_ex(context, Sync, name).await + } + + pub(crate) async fn set_name_ex( + self, + context: &Context, + sync: sync::Sync, + name: &str, + ) -> Result<()> { + let row = context .sql .transaction(|transaction| { let is_changed = transaction.execute( @@ -111,30 +121,45 @@ impl ContactId { )? > 0; if is_changed { update_chat_names(context, transaction, self)?; - let addr = transaction.query_row( - "SELECT addr FROM contacts WHERE id=?", + let (addr, fingerprint) = transaction.query_row( + "SELECT addr, fingerprint FROM contacts WHERE id=?", (self,), |row| { let addr: String = row.get(0)?; - Ok(addr) + let fingerprint: String = row.get(1)?; + Ok((addr, fingerprint)) }, )?; - Ok(Some(addr)) + context.emit_event(EventType::ContactsChanged(Some(self))); + Ok(Some((addr, fingerprint))) } else { Ok(None) } }) .await?; - if let Some(addr) = addr { - chat::sync( - context, - chat::SyncId::ContactAddr(addr.to_string()), - chat::SyncAction::Rename(name.to_string()), - ) - .await - .log_err(context) - .ok(); + if sync.into() { + if let Some((addr, fingerprint)) = row { + if fingerprint.is_empty() { + chat::sync( + context, + chat::SyncId::ContactAddr(addr), + chat::SyncAction::Rename(name.to_string()), + ) + .await + .log_err(context) + .ok(); + } else { + chat::sync( + context, + chat::SyncId::ContactFingerprint(fingerprint), + chat::SyncAction::Rename(name.to_string()), + ) + .await + .log_err(context) + .ok(); + } + } } Ok(()) } @@ -196,31 +221,6 @@ impl ContactId { .await?; Ok(addr) } - - /// Resets encryption with the contact. - /// - /// Effect is similar to receiving a message without Autocrypt header - /// from the contact, but this action is triggered manually by the user. - /// - /// For example, this will result in sending the next message - /// to 1:1 chat unencrypted, but will not remove existing verified keys. - pub async fn reset_encryption(self, context: &Context) -> Result<()> { - let now = time(); - - let addr = self.addr(context).await?; - if let Some(mut peerstate) = Peerstate::from_addr(context, &addr).await? { - peerstate.degrade_encryption(now); - peerstate.save_to_db(&context.sql).await?; - } - - // Reset 1:1 chat protection. - if let Some(chat_id) = ChatId::lookup_by_contact(context, self).await? { - chat_id - .set_protection(context, ProtectionStatus::Unprotected, now, Some(self)) - .await?; - } - Ok(()) - } } impl fmt::Display for ContactId { @@ -267,14 +267,8 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result Some(load_self_public_key(context).await?), - _ => Peerstate::from_addr(context, &c.addr) - .await? - .and_then(|peerstate| peerstate.take_key(false)), - }; - let key = key.map(|k| k.to_base64()); - let profile_image = match c.get_profile_image(context).await? { + let key = c.public_key(context).await?.map(|k| k.to_base64()); + let profile_image = match c.get_profile_image_ex(context, false).await? { None => None, Some(path) => tokio::fs::read(path) .await @@ -330,15 +324,6 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu // mustn't use `Origin::AddressBook` here because the vCard may be created not by us, also we // want `contact.authname` to be saved as the authname and not a locally given name. let origin = Origin::CreateChat; - let (id, modified) = - match Contact::add_or_lookup(context, &contact.authname, &addr, origin).await { - Err(e) => return Err(e).context("Contact::add_or_lookup() failed"), - Ok((ContactId::SELF, _)) => return Ok(ContactId::SELF), - Ok(val) => val, - }; - if modified != Modifier::None { - context.emit_event(EventType::ContactsChanged(Some(id))); - } let key = contact.key.as_ref().and_then(|k| { SignedPublicKey::from_base64(k) .with_context(|| { @@ -350,50 +335,35 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu .log_err(context) .ok() }); + + let fingerprint; if let Some(public_key) = key { - let timestamp = contact - .timestamp - .as_ref() - .map_or(0, |&t| min(t, smeared_time(context))); - let aheader = Aheader { - addr: contact.addr.clone(), - public_key, - prefer_encrypt: EncryptPreference::Mutual, - }; - let peerstate = match Peerstate::from_addr(context, &aheader.addr).await { - Err(e) => { - warn!( - context, - "import_vcard_contact: Cannot create peerstate from {}: {e:#}.", contact.addr - ); - return Ok(id); - } - Ok(p) => p, - }; - let peerstate = if let Some(mut p) = peerstate { - p.apply_gossip(&aheader, timestamp); - p - } else { - Peerstate::from_gossip(&aheader, timestamp) - }; - if let Err(e) = peerstate.save_to_db(&context.sql).await { - warn!( - context, - "import_vcard_contact: Could not save peerstate for {}: {e:#}.", contact.addr - ); - return Ok(id); - } - if let Err(e) = peerstate - .handle_fingerprint_change(context, timestamp) + fingerprint = public_key.dc_fingerprint().hex(); + + context + .sql + .execute( + "INSERT INTO public_keys (fingerprint, public_key) + VALUES (?, ?) + ON CONFLICT (fingerprint) + DO NOTHING", + (&fingerprint, public_key.to_bytes()), + ) + .await?; + } else { + fingerprint = String::new(); + } + + let (id, modified) = + match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin) .await { - warn!( - context, - "import_vcard_contact: handle_fingerprint_change() failed for {}: {e:#}.", - contact.addr - ); - return Ok(id); - } + Err(e) => return Err(e).context("Contact::add_or_lookup() failed"), + Ok((ContactId::SELF, _)) => return Ok(ContactId::SELF), + Ok(val) => val, + }; + if modified != Modifier::None { + context.emit_event(EventType::ContactsChanged(Some(id))); } if modified != Modifier::Created { return Ok(id); @@ -465,6 +435,11 @@ pub struct Contact { /// E-Mail-Address of the contact. It is recommended to use `Contact::get_addr` to access this field. addr: String, + /// OpenPGP key fingerprint. + /// Non-empty iff the contact is a key-contact, + /// identified by this fingerprint. + fingerprint: Option, + /// Blocked state. Use contact_is_blocked to access this field. pub blocked: bool, @@ -614,7 +589,7 @@ impl Contact { .sql .query_row_optional( "SELECT c.name, c.addr, c.origin, c.blocked, c.last_seen, - c.authname, c.param, c.status, c.is_bot + c.authname, c.param, c.status, c.is_bot, c.fingerprint FROM contacts c WHERE c.id=?;", (contact_id,), @@ -628,11 +603,14 @@ impl Contact { let param: String = row.get(6)?; let status: Option = row.get(7)?; let is_bot: bool = row.get(8)?; + let fingerprint: Option = + Some(row.get(9)?).filter(|s: &String| !s.is_empty()); let contact = Self { id: contact_id, name, authname, addr, + fingerprint, blocked: blocked.unwrap_or_default(), last_seen, origin, @@ -655,6 +633,9 @@ impl Contact { .get_config(Config::ConfiguredAddr) .await? .unwrap_or_default(); + if let Some(self_fp) = self_fingerprint_opt(context).await? { + contact.fingerprint = Some(self_fp.to_string()); + } contact.status = context .get_config(Config::Selfstatus) .await? @@ -812,9 +793,10 @@ impl Contact { let id = context .sql .query_get_value( - "SELECT id FROM contacts \ - WHERE addr=?1 COLLATE NOCASE \ - AND id>?2 AND origin>=?3 AND (? OR blocked=?)", + "SELECT id FROM contacts + WHERE addr=?1 COLLATE NOCASE + AND fingerprint='' -- Do not lookup key-contacts + AND id>?2 AND origin>=?3 AND (? OR blocked=?)", ( &addr_normalized, ContactId::LAST_SPECIAL, @@ -827,8 +809,19 @@ impl Contact { Ok(id) } + pub(crate) async fn add_or_lookup( + context: &Context, + name: &str, + addr: &ContactAddress, + origin: Origin, + ) -> Result<(ContactId, Modifier)> { + Self::add_or_lookup_ex(context, name, addr, "", origin).await + } + /// Lookup a contact and create it if it does not exist yet. - /// The contact is identified by the email-address, a name and an "origin" can be given. + /// If `fingerprint` is non-empty, a key-contact with this fingerprint is added / looked up. + /// Otherwise, an address-contact with `addr` is added / looked up. + /// A name and an "origin" can be given. /// /// The "origin" is where the address comes from - /// from-header, cc-header, addressbook, qr, manual-edit etc. @@ -852,21 +845,32 @@ impl Contact { /// Depending on the origin, both, "row_name" and "row_authname" are updated from "name". /// /// Returns the contact_id and a `Modifier` value indicating if a modification occurred. - pub(crate) async fn add_or_lookup( + pub(crate) async fn add_or_lookup_ex( context: &Context, name: &str, - addr: &ContactAddress, + addr: &str, + fingerprint: &str, mut origin: Origin, ) -> Result<(ContactId, Modifier)> { let mut sth_modified = Modifier::None; - ensure!(!addr.is_empty(), "Can not add_or_lookup empty address"); + ensure!( + !addr.is_empty() || !fingerprint.is_empty(), + "Can not add_or_lookup empty address" + ); ensure!(origin != Origin::Unknown, "Missing valid origin"); if context.is_self_addr(addr).await? { return Ok((ContactId::SELF, sth_modified)); } + if !fingerprint.is_empty() { + let fingerprint_self = self_fingerprint(context).await?; + if fingerprint == fingerprint_self { + return Ok((ContactId::SELF, sth_modified)); + } + } + let mut name = sanitize_name(name); if origin <= Origin::OutgoingTo { // The user may accidentally have written to a "noreply" address with another MUA: @@ -902,8 +906,10 @@ impl Contact { let row = transaction .query_row( "SELECT id, name, addr, origin, authname - FROM contacts WHERE addr=? COLLATE NOCASE", - (addr,), + FROM contacts + WHERE fingerprint=?1 AND + (?1<>'' OR addr=?2 COLLATE NOCASE)", + (fingerprint, addr), |row| { let row_id: u32 = row.get(0)?; let row_name: String = row.get(1)?; @@ -927,7 +933,7 @@ impl Contact { || row_authname.is_empty()); row_id = id; - if origin >= row_origin && addr.as_ref() != row_addr { + if origin >= row_origin && addr != row_addr { update_addr = true; } if update_name || update_authname || update_addr || origin > row_origin { @@ -971,11 +977,12 @@ impl Contact { let update_authname = !manual; transaction.execute( - "INSERT INTO contacts (name, addr, origin, authname) - VALUES (?, ?, ?, ?);", + "INSERT INTO contacts (name, addr, fingerprint, origin, authname) + VALUES (?, ?, ?, ?, ?);", ( if update_name { &name } else { "" }, &addr, + fingerprint, origin, if update_authname { &name } else { "" }, ), @@ -983,7 +990,14 @@ impl Contact { sth_modified = Modifier::Created; row_id = u32::try_from(transaction.last_insert_rowid())?; - info!(context, "Added contact id={row_id} addr={addr}."); + if fingerprint.is_empty() { + info!(context, "Added contact id={row_id} addr={addr}."); + } else { + info!( + context, + "Added contact id={row_id} fpr={fingerprint} addr={addr}." + ); + } } Ok(row_id) }) @@ -1076,8 +1090,8 @@ impl Contact { .sql .query_map( "SELECT c.id, c.addr FROM contacts c - LEFT JOIN acpeerstates ps ON c.addr=ps.addr \ WHERE c.id>? + AND c.fingerprint!='' \ AND c.origin>=? \ AND c.blocked=0 \ AND (iif(c.name='',c.authname,c.name) LIKE ? OR c.addr LIKE ?) \ @@ -1133,6 +1147,7 @@ impl Contact { .query_map( "SELECT id, addr FROM contacts WHERE id>? + AND fingerprint!='' AND origin>=? AND blocked=0 ORDER BY last_seen DESC, id DESC;", @@ -1253,17 +1268,16 @@ impl Contact { .get_config(Config::ConfiguredAddr) .await? .unwrap_or_default(); - let peerstate = Peerstate::from_addr(context, &contact.addr).await?; - let Some(peerstate) = peerstate.filter(|peerstate| peerstate.peek_key(false).is_some()) - else { + let Some(fingerprint_other) = contact.fingerprint() else { return Ok(stock_str::encr_none(context).await); }; + let fingerprint_other = fingerprint_other.to_string(); - let stock_message = match peerstate.prefer_encrypt { - EncryptPreference::Mutual => stock_str::e2e_preferred(context).await, - EncryptPreference::NoPreference => stock_str::e2e_available(context).await, - EncryptPreference::Reset => stock_str::encr_none(context).await, + let stock_message = if contact.public_key(context).await?.is_some() { + stock_str::e2e_available(context).await + } else { + stock_str::encr_none(context).await }; let finger_prints = stock_str::finger_prints(context).await; @@ -1273,43 +1287,31 @@ impl Contact { .await? .dc_fingerprint() .to_string(); - let fingerprint_other_verified = peerstate - .peek_key(true) - .map(|k| k.dc_fingerprint().to_string()) - .unwrap_or_default(); - let fingerprint_other_unverified = peerstate - .peek_key(false) - .map(|k| k.dc_fingerprint().to_string()) - .unwrap_or_default(); - if addr < peerstate.addr { + if addr < contact.addr { cat_fingerprint( &mut ret, &stock_str::self_msg(context).await, &addr, &fingerprint_self, - "", ); cat_fingerprint( &mut ret, contact.get_display_name(), - &peerstate.addr, - &fingerprint_other_verified, - &fingerprint_other_unverified, + &contact.addr, + &fingerprint_other, ); } else { cat_fingerprint( &mut ret, contact.get_display_name(), - &peerstate.addr, - &fingerprint_other_verified, - &fingerprint_other_unverified, + &contact.addr, + &fingerprint_other, ); cat_fingerprint( &mut ret, &stock_str::self_msg(context).await, &addr, &fingerprint_self, - "", ); } @@ -1382,6 +1384,59 @@ impl Contact { &self.addr } + /// Returns true if the contact is a key-contact. + /// Otherwise it is an addresss-contact. + pub fn is_key_contact(&self) -> bool { + self.fingerprint.is_some() + } + + /// Returns OpenPGP fingerprint of a contact. + /// + /// `None` for address-contacts. + pub fn fingerprint(&self) -> Option { + if let Some(fingerprint) = &self.fingerprint { + fingerprint.parse().ok() + } else { + None + } + } + + /// Returns OpenPGP public key of a contact. + /// + /// Returns `None` if the contact is not a key-contact + /// or if the key is not available. + /// It is possible for a key-contact to not have a key, + /// e.g. if only the fingerprint is known from a QR-code. + pub async fn public_key(&self, context: &Context) -> Result> { + if self.id == ContactId::SELF { + return Ok(Some(load_self_public_key(context).await?)); + } + + if let Some(fingerprint) = &self.fingerprint { + if let Some(public_key_bytes) = context + .sql + .query_row_optional( + "SELECT public_key + FROM public_keys + WHERE fingerprint=?", + (fingerprint,), + |row| { + let bytes: Vec = row.get(0)?; + Ok(bytes) + }, + ) + .await? + { + let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; + Ok(Some(public_key)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + /// Get name authorized by the contact. pub fn get_authname(&self) -> &str { &self.authname @@ -1447,11 +1502,28 @@ impl Contact { /// This is the image set by each remote user on their own /// using set_config(context, "selfavatar", image). pub async fn get_profile_image(&self, context: &Context) -> Result> { + self.get_profile_image_ex(context, true).await + } + + /// Get the contact's profile image. + /// This is the image set by each remote user on their own + /// using set_config(context, "selfavatar", image). + async fn get_profile_image_ex( + &self, + context: &Context, + show_fallback_icon: bool, + ) -> Result> { if self.id == ContactId::SELF { if let Some(p) = context.get_config(Config::Selfavatar).await? { return Ok(Some(PathBuf::from(p))); // get_config() calls get_abs_path() internally already } - } else if let Some(image_rel) = self.param.get(Param::ProfileImage) { + } else if self.id == ContactId::DEVICE { + return Ok(Some(chat::get_device_icon(context).await?)); + } + if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() { + return Ok(Some(chat::get_address_contact_icon(context).await?)); + } + if let Some(image_rel) = self.param.get(Param::ProfileImage) { if !image_rel.is_empty() { return Ok(Some(get_abs_path(context, Path::new(image_rel)))); } @@ -1477,25 +1549,21 @@ impl Contact { /// Returns whether end-to-end encryption to the contact is available. pub async fn e2ee_avail(&self, context: &Context) -> Result { if self.id == ContactId::SELF { + // We don't need to check if we have our own key. return Ok(true); } - let Some(peerstate) = Peerstate::from_addr(context, &self.addr).await? else { - return Ok(false); - }; - Ok(peerstate.peek_key(false).is_some()) + Ok(self.public_key(context).await?.is_some()) } /// Returns true if the contact - /// can be added to verified chats, - /// i.e. has a verified key - /// and Autocrypt key matches the verified key. + /// can be added to verified chats. /// /// If contact is verified /// UI should display green checkmark after the contact name /// in contact list items and /// in chat member list items. /// - /// In contact profile view, us this function only if there is no chat with the contact, + /// In contact profile view, use this function only if there is no chat with the contact, /// otherwise use is_chat_protected(). /// Use [Self::get_verifier_id] to display the verifier contact /// in the info section of the contact profile. @@ -1506,64 +1574,31 @@ impl Contact { return Ok(true); } - let Some(peerstate) = Peerstate::from_addr(context, &self.addr).await? else { - return Ok(false); - }; - - let forward_verified = peerstate.is_using_verified_key(); - let backward_verified = peerstate.is_backward_verified(context).await?; - Ok(forward_verified && backward_verified) - } - - /// Returns true if we have a verified key for the contact - /// and it is the same as Autocrypt key. - /// This is enough to send messages to the contact in verified chat - /// and verify received messages, but not enough to display green checkmark - /// or add the contact to verified groups. - pub async fn is_forward_verified(&self, context: &Context) -> Result { - if self.id == ContactId::SELF { - return Ok(true); - } - - let Some(peerstate) = Peerstate::from_addr(context, &self.addr).await? else { - return Ok(false); - }; - - Ok(peerstate.is_using_verified_key()) + Ok(self.get_verifier_id(context).await?.is_some()) } /// Returns the `ContactId` that verified the contact. /// - /// If the function returns non-zero result, + /// If this returns Some(_), /// display green checkmark in the profile and "Introduced by ..." line /// with the name and address of the contact /// formatted by [Self::get_name_n_addr]. /// - /// If this function returns a verifier, - /// this does not necessarily mean - /// you can add the contact to verified chats. - /// Use [Self::is_verified] to check - /// if a contact can be added to a verified chat instead. - pub async fn get_verifier_id(&self, context: &Context) -> Result> { - let Some(verifier_addr) = Peerstate::from_addr(context, self.get_addr()) + /// If this returns `Some(None)`, then the contact is verified, + /// but it's unclear by whom. + pub async fn get_verifier_id(&self, context: &Context) -> Result>> { + let verifier_id: u32 = context + .sql + .query_get_value("SELECT verifier FROM contacts WHERE id=?", (self.id,)) .await? - .and_then(|peerstate| peerstate.get_verifier().map(|addr| addr.to_owned())) - else { - return Ok(None); - }; + .with_context(|| format!("Contact {} does not exist", self.id))?; - if addr_cmp(&verifier_addr, &self.addr) { - // Contact is directly verified via QR code. - return Ok(Some(ContactId::SELF)); - } - - match Contact::lookup_id_by_addr(context, &verifier_addr, Origin::Unknown).await? { - Some(contact_id) => Ok(Some(contact_id)), - None => { - let addr = &self.addr; - warn!(context, "Could not lookup contact with address {verifier_addr} which introduced {addr}."); - Ok(None) - } + if verifier_id == 0 { + Ok(None) + } else if verifier_id == self.id.to_u32() { + Ok(Some(None)) + } else { + Ok(Some(Some(ContactId::new(verifier_id)))) } } @@ -1735,14 +1770,16 @@ WHERE type=? AND id IN ( true => chat::SyncAction::Block, false => chat::SyncAction::Unblock, }; - chat::sync( - context, - chat::SyncId::ContactAddr(contact.addr.clone()), - action, - ) - .await - .log_err(context) - .ok(); + let sync_id = if let Some(fingerprint) = contact.fingerprint() { + chat::SyncId::ContactFingerprint(fingerprint.hex()) + } else { + chat::SyncId::ContactAddr(contact.addr.clone()) + }; + + chat::sync(context, sync_id, action) + .await + .log_err(context) + .ok(); } } @@ -1862,29 +1899,51 @@ pub(crate) async fn update_last_seen( Ok(()) } -fn cat_fingerprint( - ret: &mut String, - name: &str, - addr: &str, - fingerprint_verified: &str, - fingerprint_unverified: &str, -) { - *ret += &format!( - "\n\n{} ({}):\n{}", - name, - addr, - if !fingerprint_verified.is_empty() { - fingerprint_verified - } else { - fingerprint_unverified - }, +/// Marks contact `contact_id` as verified by `verifier_id`. +pub(crate) async fn mark_contact_id_as_verified( + context: &Context, + contact_id: ContactId, + verifier_id: ContactId, +) -> Result<()> { + debug_assert_ne!( + contact_id, verifier_id, + "Contact cannot be verified by self" ); - if !fingerprint_verified.is_empty() - && !fingerprint_unverified.is_empty() - && fingerprint_verified != fingerprint_unverified - { - *ret += &format!("\n\n{name} (alternative):\n{fingerprint_unverified}"); - } + context + .sql + .transaction(|transaction| { + let contact_fingerprint: String = transaction.query_row( + "SELECT fingerprint FROM contacts WHERE id=?", + (contact_id,), + |row| row.get(0), + )?; + if contact_fingerprint.is_empty() { + bail!("Non-key-contact {contact_id} cannot be verified"); + } + if verifier_id != ContactId::SELF { + let verifier_fingerprint: String = transaction.query_row( + "SELECT fingerprint FROM contacts WHERE id=?", + (verifier_id,), + |row| row.get(0), + )?; + if verifier_fingerprint.is_empty() { + bail!( + "Contact {contact_id} cannot be verified by non-key-contact {verifier_id}" + ); + } + } + transaction.execute( + "UPDATE contacts SET verifier=? WHERE id=?", + (verifier_id, contact_id), + )?; + Ok(()) + }) + .await?; + Ok(()) +} + +fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) { + *ret += &format!("\n\n{name} ({addr}):\n{fingerprint}"); } fn split_address_book(book: &str) -> Vec<(&str, &str)> { diff --git a/src/contact/contact_tests.rs b/src/contact/contact_tests.rs index 2acf699f1..ae07c65d4 100644 --- a/src/contact/contact_tests.rs +++ b/src/contact/contact_tests.rs @@ -1,4 +1,4 @@ -use deltachat_contact_tools::may_be_valid_addr; +use deltachat_contact_tools::{addr_cmp, may_be_valid_addr}; use super::*; use crate::chat::{get_chat_contacts, send_text_msg, Chat}; @@ -56,58 +56,49 @@ fn test_split_address_book() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_contacts() -> Result<()> { - let context = TestContext::new().await; + let mut tcm = TestContextManager::new(); + let context = tcm.bob().await; + let alice = tcm.alice().await; + alice + .set_config(Config::Displayname, Some("MyName")) + .await?; - assert!(context.get_all_self_addrs().await?.is_empty()); - - // Bob is not in the contacts yet. - let contacts = Contact::get_all(&context.ctx, 0, Some("bob")).await?; + // Alice is not in the contacts yet. + let contacts = Contact::get_all(&context.ctx, 0, Some("Alice")).await?; + assert_eq!(contacts.len(), 0); + let contacts = Contact::get_all(&context.ctx, 0, Some("MyName")).await?; assert_eq!(contacts.len(), 0); - let (id, _modified) = Contact::add_or_lookup( - &context.ctx, - "bob", - &ContactAddress::new("user@example.org")?, - Origin::IncomingReplyTo, - ) - .await?; + let id = context.add_or_lookup_contact_id(&alice).await; assert_ne!(id, ContactId::UNDEFINED); - let contact = Contact::get_by_id(&context.ctx, id).await.unwrap(); + let contact = Contact::get_by_id(&context, id).await.unwrap(); assert_eq!(contact.get_name(), ""); - assert_eq!(contact.get_authname(), "bob"); - assert_eq!(contact.get_display_name(), "bob"); + assert_eq!(contact.get_authname(), "MyName"); + assert_eq!(contact.get_display_name(), "MyName"); // Search by name. - let contacts = Contact::get_all(&context.ctx, 0, Some("bob")).await?; + let contacts = Contact::get_all(&context, 0, Some("myname")).await?; assert_eq!(contacts.len(), 1); assert_eq!(contacts.first(), Some(&id)); // Search by address. - let contacts = Contact::get_all(&context.ctx, 0, Some("user")).await?; + let contacts = Contact::get_all(&context, 0, Some("alice@example.org")).await?; assert_eq!(contacts.len(), 1); assert_eq!(contacts.first(), Some(&id)); - let contacts = Contact::get_all(&context.ctx, 0, Some("alice")).await?; + let contacts = Contact::get_all(&context, 0, Some("Foobar")).await?; assert_eq!(contacts.len(), 0); - // Set Bob name to "someone" manually. - let (contact_bob_id, modified) = Contact::add_or_lookup( - &context.ctx, - "someone", - &ContactAddress::new("user@example.org")?, - Origin::ManuallyCreated, - ) - .await?; - assert_eq!(contact_bob_id, id); - assert_eq!(modified, Modifier::Modified); + // Set Alice name to "someone" manually. + id.set_name(&context, "someone").await?; let contact = Contact::get_by_id(&context.ctx, id).await.unwrap(); assert_eq!(contact.get_name(), "someone"); - assert_eq!(contact.get_authname(), "bob"); + assert_eq!(contact.get_authname(), "MyName"); assert_eq!(contact.get_display_name(), "someone"); // Not searchable by authname, because it is not displayed. - let contacts = Contact::get_all(&context.ctx, 0, Some("bob")).await?; + let contacts = Contact::get_all(&context, 0, Some("MyName")).await?; assert_eq!(contacts.len(), 0); // Search by display name (same as manually set name). @@ -133,7 +124,7 @@ async fn test_is_self_addr() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_add_or_lookup() { // add some contacts, this also tests add_address_book() - let t = TestContext::new().await; + let t = TestContext::new_alice().await; let book = concat!( " Name one \n one@eins.org \n", "Name two\ntwo@deux.net\n", @@ -247,7 +238,7 @@ async fn test_add_or_lookup() { // check SELF let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap(); assert_eq!(contact.get_name(), stock_str::self_msg(&t).await); - assert_eq!(contact.get_addr(), ""); // we're not configured + assert_eq!(contact.get_addr(), "alice@example.org"); assert!(!contact.is_blocked()); } @@ -282,7 +273,7 @@ async fn test_contact_name_changes() -> Result<()> { assert_eq!(contact.get_display_name(), "f@example.org"); assert_eq!(contact.get_name_n_addr(), "f@example.org"); let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); // second message inits the name receive_imf( @@ -308,9 +299,9 @@ async fn test_contact_name_changes() -> Result<()> { assert_eq!(contact.get_display_name(), "Flobbyfoo"); assert_eq!(contact.get_name_n_addr(), "Flobbyfoo (f@example.org)"); let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); // third message changes the name receive_imf( @@ -338,11 +329,11 @@ async fn test_contact_name_changes() -> Result<()> { assert_eq!(contact.get_display_name(), "Foo Flobby"); assert_eq!(contact.get_name_n_addr(), "Foo Flobby (f@example.org)"); let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?; assert_eq!(contacts.len(), 0); let contacts = Contact::get_all(&t, 0, Some("Foo Flobby")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); // change name manually let test_id = Contact::create(&t, "Falk", "f@example.org").await?; @@ -356,9 +347,9 @@ async fn test_contact_name_changes() -> Result<()> { assert_eq!(contact.get_display_name(), "Falk"); assert_eq!(contact.get_name_n_addr(), "Falk (f@example.org)"); let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); let contacts = Contact::get_all(&t, 0, Some("falk")).await?; - assert_eq!(contacts.len(), 1); + assert_eq!(contacts.len(), 0); Ok(()) } @@ -366,20 +357,13 @@ async fn test_contact_name_changes() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete() -> Result<()> { let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; assert!(Contact::delete(&alice, ContactId::SELF).await.is_err()); // Create Bob contact - let (contact_id, _) = Contact::add_or_lookup( - &alice, - "Bob", - &ContactAddress::new("bob@example.net")?, - Origin::ManuallyCreated, - ) - .await?; - let chat = alice - .create_chat_with_contact("Bob", "bob@example.net") - .await; + let contact_id = alice.add_or_lookup_contact_id(&bob).await; + let chat = alice.create_chat(&bob).await; assert_eq!( Contact::get_all(&alice, 0, Some("bob@example.net")) .await? @@ -416,30 +400,57 @@ async fn test_delete() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_and_recreate_contact() -> Result<()> { - let t = TestContext::new_alice().await; + let mut tcm = TestContextManager::new(); + let t = tcm.alice().await; + let bob = tcm.bob().await; // test recreation after physical deletion - let contact_id1 = Contact::create(&t, "Foo", "foo@bar.de").await?; - assert_eq!(Contact::get_all(&t, 0, Some("foo@bar.de")).await?.len(), 1); + let contact_id1 = t.add_or_lookup_contact_id(&bob).await; + assert_eq!( + Contact::get_all(&t, 0, Some("bob@example.net")) + .await? + .len(), + 1 + ); Contact::delete(&t, contact_id1).await?; assert!(Contact::get_by_id(&t, contact_id1).await.is_err()); - assert_eq!(Contact::get_all(&t, 0, Some("foo@bar.de")).await?.len(), 0); - let contact_id2 = Contact::create(&t, "Foo", "foo@bar.de").await?; + assert_eq!( + Contact::get_all(&t, 0, Some("bob@example.net")) + .await? + .len(), + 0 + ); + let contact_id2 = t.add_or_lookup_contact_id(&bob).await; assert_ne!(contact_id2, contact_id1); - assert_eq!(Contact::get_all(&t, 0, Some("foo@bar.de")).await?.len(), 1); + assert_eq!( + Contact::get_all(&t, 0, Some("bob@example.net")) + .await? + .len(), + 1 + ); // test recreation after hiding - t.create_chat_with_contact("Foo", "foo@bar.de").await; + t.create_chat(&bob).await; Contact::delete(&t, contact_id2).await?; let contact = Contact::get_by_id(&t, contact_id2).await?; assert_eq!(contact.origin, Origin::Hidden); - assert_eq!(Contact::get_all(&t, 0, Some("foo@bar.de")).await?.len(), 0); + assert_eq!( + Contact::get_all(&t, 0, Some("bob@example.net")) + .await? + .len(), + 0 + ); - let contact_id3 = Contact::create(&t, "Foo", "foo@bar.de").await?; + let contact_id3 = t.add_or_lookup_contact_id(&bob).await; let contact = Contact::get_by_id(&t, contact_id3).await?; - assert_eq!(contact.origin, Origin::ManuallyCreated); + assert_eq!(contact.origin, Origin::CreateChat); assert_eq!(contact_id3, contact_id2); - assert_eq!(Contact::get_all(&t, 0, Some("foo@bar.de")).await?.len(), 1); + assert_eq!( + Contact::get_all(&t, 0, Some("bob@example.net")) + .await? + .len(), + 1 + ); Ok(()) } @@ -728,51 +739,59 @@ async fn test_contact_get_color() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_contact_get_encrinfo() -> Result<()> { - let alice = TestContext::new_alice().await; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; // Return error for special IDs - let encrinfo = Contact::get_encrinfo(&alice, ContactId::SELF).await; + let encrinfo = Contact::get_encrinfo(alice, ContactId::SELF).await; assert!(encrinfo.is_err()); - let encrinfo = Contact::get_encrinfo(&alice, ContactId::DEVICE).await; + let encrinfo = Contact::get_encrinfo(alice, ContactId::DEVICE).await; assert!(encrinfo.is_err()); - let (contact_bob_id, _modified) = Contact::add_or_lookup( - &alice, - "Bob", - &ContactAddress::new("bob@example.net")?, - Origin::ManuallyCreated, - ) - .await?; - - let encrinfo = Contact::get_encrinfo(&alice, contact_bob_id).await?; + let address_contact_bob_id = alice.add_or_lookup_address_contact_id(bob).await; + let encrinfo = Contact::get_encrinfo(alice, address_contact_bob_id).await?; assert_eq!(encrinfo, "No encryption"); - let contact = Contact::get_by_id(&alice, contact_bob_id).await?; - assert!(!contact.e2ee_avail(&alice).await?); - let bob = TestContext::new_bob().await; - let chat_alice = bob - .create_chat_with_contact("Alice", "alice@example.org") - .await; - send_text_msg(&bob, chat_alice.id, "Hello".to_string()).await?; - let msg = bob.pop_sent_msg().await; - alice.recv_msg(&msg).await; + let contact = Contact::get_by_id(alice, address_contact_bob_id).await?; + assert!(!contact.e2ee_avail(alice).await?); - let encrinfo = Contact::get_encrinfo(&alice, contact_bob_id).await?; + let contact_bob_id = alice.add_or_lookup_contact_id(bob).await; + let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?; assert_eq!( encrinfo, - "End-to-end encryption preferred. + "End-to-end encryption available. Fingerprints: Me (alice@example.org): 2E6F A2CB 23B5 32D7 2863 4B58 64B0 8F61 A9ED 9443 -Bob (bob@example.net): +bob@example.net (bob@example.net): CCCB 5AA9 F6E1 141C 9431 65F1 DB18 B18C BCF7 0487" ); - let contact = Contact::get_by_id(&alice, contact_bob_id).await?; - assert!(contact.e2ee_avail(&alice).await?); + let contact = Contact::get_by_id(alice, contact_bob_id).await?; + assert!(contact.e2ee_avail(alice).await?); + + alice.sql.execute("DELETE FROM public_keys", ()).await?; + let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?; + assert_eq!( + encrinfo, + "No encryption. +Fingerprints: + +Me (alice@example.org): +2E6F A2CB 23B5 32D7 2863 +4B58 64B0 8F61 A9ED 9443 + +bob@example.net (bob@example.net): +CCCB 5AA9 F6E1 141C 9431 +65F1 DB18 B18C BCF7 0487" + ); + let contact = Contact::get_by_id(alice, contact_bob_id).await?; + assert!(!contact.e2ee_avail(alice).await?); + Ok(()) } @@ -780,24 +799,24 @@ CCCB 5AA9 F6E1 141C 9431 /// synchronized when the message is not encrypted. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_synchronize_status() -> Result<()> { + let mut tcm = TestContextManager::new(); + // Alice has two devices. - let alice1 = TestContext::new_alice().await; - let alice2 = TestContext::new_alice().await; + let alice1 = &tcm.alice().await; + let alice2 = &tcm.alice().await; // Bob has one device. - let bob = TestContext::new_bob().await; + let bob = &tcm.bob().await; let default_status = alice1.get_config(Config::Selfstatus).await?; alice1 .set_config(Config::Selfstatus, Some("New status")) .await?; - let chat = alice1 - .create_chat_with_contact("Bob", "bob@example.net") - .await; + let chat = alice1.create_email_chat(bob).await; // Alice sends a message to Bob from the first device. - send_text_msg(&alice1, chat.id, "Hello".to_string()).await?; + send_text_msg(alice1, chat.id, "Hello".to_string()).await?; let sent_msg = alice1.pop_sent_msg().await; // Message is not encrypted. @@ -813,18 +832,9 @@ async fn test_synchronize_status() -> Result<()> { // Message was not encrypted, so status is not copied. assert_eq!(alice2.get_config(Config::Selfstatus).await?, default_status); - // Bob replies. - let chat = bob - .create_chat_with_contact("Alice", "alice@example.org") - .await; - - send_text_msg(&bob, chat.id, "Reply".to_string()).await?; - let sent_msg = bob.pop_sent_msg().await; - alice1.recv_msg(&sent_msg).await; - alice2.recv_msg(&sent_msg).await; - - // Alice sends second message. - send_text_msg(&alice1, chat.id, "Hello".to_string()).await?; + // Alice sends encrypted message. + let chat = alice1.create_chat(bob).await; + send_text_msg(alice1, chat.id, "Hello".to_string()).await?; let sent_msg = alice1.pop_sent_msg().await; // Second message is encrypted. @@ -845,12 +855,14 @@ async fn test_synchronize_status() -> Result<()> { /// Tests that DC_EVENT_SELFAVATAR_CHANGED is emitted on avatar changes. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_selfavatar_changed_event() -> Result<()> { + let mut tcm = TestContextManager::new(); + // Alice has two devices. - let alice1 = TestContext::new_alice().await; - let alice2 = TestContext::new_alice().await; + let alice1 = &tcm.alice().await; + let alice2 = &tcm.alice().await; // Bob has one device. - let bob = TestContext::new_bob().await; + let bob = &tcm.bob().await; assert_eq!(alice1.get_config(Config::Selfavatar).await?, None); @@ -866,20 +878,9 @@ async fn test_selfavatar_changed_event() -> Result<()> { .get_matching(|e| matches!(e, EventType::SelfavatarChanged)) .await; - // Bob sends a message so that Alice can encrypt to him. - let chat = bob - .create_chat_with_contact("Alice", "alice@example.org") - .await; - - send_text_msg(&bob, chat.id, "Reply".to_string()).await?; - let sent_msg = bob.pop_sent_msg().await; - alice1.recv_msg(&sent_msg).await; - alice2.recv_msg(&sent_msg).await; - // Alice sends a message. - let alice1_chat_id = alice1.get_last_msg().await.chat_id; - alice1_chat_id.accept(&alice1).await?; - send_text_msg(&alice1, alice1_chat_id, "Hello".to_string()).await?; + let alice1_chat_id = alice1.create_chat(bob).await.id; + send_text_msg(alice1, alice1_chat_id, "Hello".to_string()).await?; let sent_msg = alice1.pop_sent_msg().await; // The message is encrypted. @@ -1008,7 +1009,7 @@ async fn test_verified_by_none() -> Result<()> { let contact = Contact::get_by_id(&alice, contact_id).await?; assert!(contact.get_verifier_id(&alice).await?.is_none()); - // Receive a message from Bob to create a peerstate. + // Receive a message from Bob to save the public key. let chat = bob.create_chat(&alice).await; let sent_msg = bob.send_text(chat.id, "moin").await; alice.recv_msg(&sent_msg).await; @@ -1066,14 +1067,9 @@ async fn test_make_n_import_vcard() -> Result<()> { let bob_biography = bob.get_config(Config::Selfstatus).await?.unwrap(); let chat = bob.create_chat(alice).await; let sent_msg = bob.send_text(chat.id, "moin").await; - alice.recv_msg(&sent_msg).await; - let bob_id = Contact::create(alice, "Some Bob", &bob_addr).await?; - let key_base64 = Peerstate::from_addr(alice, &bob_addr) - .await? - .unwrap() - .peek_key(false) - .unwrap() - .to_base64(); + let bob_id = alice.recv_msg(&sent_msg).await.from_id; + let bob_contact = Contact::get_by_id(alice, bob_id).await?; + let key_base64 = bob_contact.public_key(alice).await?.unwrap().to_base64(); let fiona_id = Contact::create(alice, "Fiona", "fiona@example.net").await?; assert_eq!(make_vcard(alice, &[]).await?, "".to_string()); @@ -1157,8 +1153,10 @@ async fn test_make_n_import_vcard() -> Result<()> { Ok(()) } +/// Tests importing a vCard with the same email address, +/// but a new key. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_import_vcard_updates_only_key() -> Result<()> { +async fn test_import_vcard_key_change() -> Result<()> { let alice = &TestContext::new_alice().await; let bob = &TestContext::new_bob().await; let bob_addr = &bob.get_config(Config::Addr).await?.unwrap(); @@ -1176,28 +1174,34 @@ async fn test_import_vcard_updates_only_key() -> Result<()> { let msg = bob.recv_msg(&sent_msg).await; assert!(msg.get_showpadlock()); - let bob = &TestContext::new().await; - bob.configure_addr(bob_addr).await; - bob.set_config(Config::Displayname, Some("Not Bob")).await?; - let avatar_path = bob.dir.path().join("avatar.png"); + let bob1 = &TestContext::new().await; + bob1.configure_addr(bob_addr).await; + bob1.set_config(Config::Displayname, Some("New Bob")) + .await?; + let avatar_path = bob1.dir.path().join("avatar.png"); let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png"); tokio::fs::write(&avatar_path, avatar_bytes).await?; - bob.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap())) + bob1.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap())) .await?; SystemTime::shift(Duration::from_secs(1)); - let vcard1 = make_vcard(bob, &[ContactId::SELF]).await?; - assert_eq!(import_vcard(alice, &vcard1).await?, vec![alice_bob_id]); + let vcard1 = make_vcard(bob1, &[ContactId::SELF]).await?; + let alice_bob_id1 = import_vcard(alice, &vcard1).await?[0]; + assert_ne!(alice_bob_id1, alice_bob_id); let alice_bob_contact = Contact::get_by_id(alice, alice_bob_id).await?; assert_eq!(alice_bob_contact.get_authname(), "Bob"); assert_eq!(alice_bob_contact.get_profile_image(alice).await?, None); + let alice_bob_contact1 = Contact::get_by_id(alice, alice_bob_id1).await?; + assert_eq!(alice_bob_contact1.get_authname(), "New Bob"); + assert!(alice_bob_contact1.get_profile_image(alice).await?.is_some()); + + // Last message is still the same, + // no new messages are added. let msg = alice.get_last_msg_in(chat_id).await; - assert!(msg.is_info()); - assert_eq!( - msg.get_text(), - stock_str::contact_setup_changed(alice, bob_addr).await - ); - let sent_msg = alice.send_text(chat_id, "moin").await; - let msg = bob.recv_msg(&sent_msg).await; + assert_eq!(msg.get_text(), "moin"); + + let chat_id1 = ChatId::create_for_contact(alice, alice_bob_id1).await?; + let sent_msg = alice.send_text(chat_id1, "moin").await; + let msg = bob1.recv_msg(&sent_msg).await; assert!(msg.get_showpadlock()); // The old vCard is imported, but doesn't change Bob's key for Alice. @@ -1209,63 +1213,6 @@ async fn test_import_vcard_updates_only_key() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_reset_encryption() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - let msg = tcm.send_recv_accept(bob, alice, "Hi!").await; - assert_eq!(msg.get_showpadlock(), true); - - let alice_bob_chat_id = msg.chat_id; - let alice_bob_contact_id = msg.from_id; - - alice_bob_contact_id.reset_encryption(alice).await?; - - let sent = alice.send_text(alice_bob_chat_id, "Unencrypted").await; - let msg = bob.recv_msg(&sent).await; - assert_eq!(msg.get_showpadlock(), false); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_reset_verified_encryption() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - tcm.execute_securejoin(bob, alice).await; - - let msg = tcm.send_recv(bob, alice, "Encrypted").await; - assert_eq!(msg.get_showpadlock(), true); - - let alice_bob_chat_id = msg.chat_id; - let alice_bob_contact_id = msg.from_id; - - alice_bob_contact_id.reset_encryption(alice).await?; - - // Check that the contact is still verified after resetting encryption. - let alice_bob_contact = Contact::get_by_id(alice, alice_bob_contact_id).await?; - assert_eq!(alice_bob_contact.is_verified(alice).await?, true); - - // 1:1 chat and profile is no longer verified. - assert_eq!(alice_bob_contact.is_profile_verified(alice).await?, false); - - let info_msg = alice.get_last_msg_in(alice_bob_chat_id).await; - assert_eq!( - info_msg.text, - "bob@example.net sent a message from another device." - ); - - let sent = alice.send_text(alice_bob_chat_id, "Unencrypted").await; - let msg = bob.recv_msg(&sent).await; - assert_eq!(msg.get_showpadlock(), false); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_self_is_verified() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -1275,9 +1222,87 @@ async fn test_self_is_verified() -> Result<()> { assert_eq!(contact.is_verified(&alice).await?, true); assert!(contact.is_profile_verified(&alice).await?); assert!(contact.get_verifier_id(&alice).await?.is_none()); + assert!(contact.is_key_contact()); let chat_id = ChatId::get_for_contact(&alice, ContactId::SELF).await?; assert!(chat_id.is_protected(&alice).await.unwrap() == ProtectionStatus::Protected); Ok(()) } + +/// Tests that importing a vCard with a key creates a key-contact. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_vcard_creates_key_contact() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let vcard = make_vcard(bob, &[ContactId::SELF]).await?; + let contact_ids = import_vcard(alice, &vcard).await?; + assert_eq!(contact_ids.len(), 1); + let contact_id = contact_ids.first().unwrap(); + let contact = Contact::get_by_id(alice, *contact_id).await?; + assert!(contact.is_key_contact()); + + Ok(()) +} + +/// Tests changing display name by sending a message. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_name_changes() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + alice + .set_config(Config::Displayname, Some("Alice Revision 1")) + .await?; + let alice_bob_chat = alice.create_chat(bob).await; + let sent_msg = alice.send_text(alice_bob_chat.id, "Hello").await; + let bob_alice_id = bob.recv_msg(&sent_msg).await.from_id; + let bob_alice_contact = Contact::get_by_id(bob, bob_alice_id).await?; + assert_eq!(bob_alice_contact.get_display_name(), "Alice Revision 1"); + + alice + .set_config(Config::Displayname, Some("Alice Revision 2")) + .await?; + let sent_msg = alice.send_text(alice_bob_chat.id, "Hello").await; + bob.recv_msg(&sent_msg).await; + let bob_alice_contact = Contact::get_by_id(bob, bob_alice_id).await?; + assert_eq!(bob_alice_contact.get_display_name(), "Alice Revision 2"); + + // Explicitly rename contact to "Renamed". + bob.evtracker.clear_events(); + bob_alice_contact.id.set_name(bob, "Renamed").await?; + let event = bob + .evtracker + .get_matching(|e| matches!(e, EventType::ContactsChanged { .. })) + .await; + assert_eq!( + event, + EventType::ContactsChanged(Some(bob_alice_contact.id)) + ); + let bob_alice_contact = Contact::get_by_id(bob, bob_alice_id).await?; + assert_eq!(bob_alice_contact.get_display_name(), "Renamed"); + + // Alice also renames self into "Renamed". + alice + .set_config(Config::Displayname, Some("Renamed")) + .await?; + let sent_msg = alice.send_text(alice_bob_chat.id, "Hello").await; + bob.recv_msg(&sent_msg).await; + let bob_alice_contact = Contact::get_by_id(bob, bob_alice_id).await?; + assert_eq!(bob_alice_contact.get_display_name(), "Renamed"); + + // Contact name was set to "Renamed" explicitly before, + // so it should not be changed. + alice + .set_config(Config::Displayname, Some("Renamed again")) + .await?; + let sent_msg = alice.send_text(alice_bob_chat.id, "Hello").await; + bob.recv_msg(&sent_msg).await; + let bob_alice_contact = Contact::get_by_id(bob, bob_alice_id).await?; + assert_eq!(bob_alice_contact.get_display_name(), "Renamed"); + + Ok(()) +} diff --git a/src/context.rs b/src/context.rs index 858689b8e..2d1ba7655 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,35 +5,32 @@ use std::ffi::OsString; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use anyhow::{bail, ensure, Context as _, Result}; use async_channel::{self as channel, Receiver, Sender}; -use pgp::composed::SignedPublicKey; use pgp::types::PublicKeyTrait; use ratelimit::Ratelimit; use tokio::sync::{Mutex, Notify, RwLock}; -use crate::aheader::EncryptPreference; use crate::chat::{get_chat_cnt, ChatId, ProtectionStatus}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{ self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_CHAT_ID_TRASH, DC_VERSION_STR, }; -use crate::contact::{Contact, ContactId}; +use crate::contact::{import_vcard, mark_contact_id_as_verified, Contact, ContactId}; use crate::debug_logging::DebugLogging; use crate::download::DownloadState; use crate::events::{Event, EventEmitter, EventType, Events}; use crate::imap::{FolderMeaning, Imap, ServerMetadata}; -use crate::key::{load_self_public_key, load_self_secret_key, DcKey as _}; +use crate::key::{load_self_secret_key, self_fingerprint}; use crate::log::{info, warn}; use crate::login_param::{ConfiguredLoginParam, EnteredLoginParam}; use crate::message::{self, Message, MessageState, MsgId}; use crate::param::{Param, Params}; use crate::peer_channels::Iroh; -use crate::peerstate::Peerstate; use crate::push::PushSubscriber; use crate::quota::QuotaInfo; use crate::scheduler::{convert_folder_meaning, SchedulerState}; @@ -279,6 +276,13 @@ pub struct InnerContext { /// `last_error` should be used to avoid races with the event thread. pub(crate) last_error: parking_lot::RwLock, + /// It's not possible to emit migration errors as an event, + /// because at the time of the migration, there is no event emitter yet. + /// So, this holds the error that happened during migration, if any. + /// This is necessary for the possibly-failible PGP migration, + /// which happened 2025-05, and can be removed a few releases later. + pub(crate) migration_error: parking_lot::RwLock>, + /// If debug logging is enabled, this contains all necessary information /// /// Standard RwLock instead of [`tokio::sync::RwLock`] is used @@ -294,6 +298,11 @@ pub struct InnerContext { /// Iroh for realtime peer channels. pub(crate) iroh: Arc>>, + + /// The own fingerprint, if it was computed already. + /// tokio::sync::OnceCell would be possible to use, but overkill for our usecase; + /// the standard library's OnceLock is enough, and it's a lot smaller in memory. + pub(crate) self_fingerprint: OnceLock, } /// The state of ongoing process. @@ -448,10 +457,12 @@ impl Context { creation_time: tools::Time::now(), last_full_folder_scan: Mutex::new(None), last_error: parking_lot::RwLock::new("".to_string()), + migration_error: parking_lot::RwLock::new(None), debug_logging: std::sync::RwLock::new(None), push_subscriber, push_subscribed: AtomicBool::new(false), iroh: Arc::new(RwLock::new(None)), + self_fingerprint: OnceLock::new(), }; let ctx = Context { @@ -805,10 +816,10 @@ impl Context { let pub_key_cnt = self .sql - .count("SELECT COUNT(*) FROM acpeerstates;", ()) + .count("SELECT COUNT(*) FROM public_keys;", ()) .await?; - let fingerprint_str = match load_self_public_key(self).await { - Ok(key) => key.dc_fingerprint().hex(), + let fingerprint_str = match self_fingerprint(self).await { + Ok(fp) => fp.to_string(), Err(err) => format!(""), }; @@ -1164,32 +1175,14 @@ impl Context { /// On the other end, a bot will receive the message and make it available /// to Delta Chat's developers. pub async fn draft_self_report(&self) -> Result { - const SELF_REPORTING_BOT: &str = "self_reporting@testrun.org"; + const SELF_REPORTING_BOT_VCARD: &str = include_str!("../assets/self-reporting-bot.vcf"); + let contact_id: ContactId = *import_vcard(self, SELF_REPORTING_BOT_VCARD) + .await? + .first() + .context("Self reporting bot vCard does not contain a contact")?; + mark_contact_id_as_verified(self, contact_id, ContactId::SELF).await?; - let contact_id = Contact::create(self, "Statistics bot", SELF_REPORTING_BOT).await?; let chat_id = ChatId::create_for_contact(self, contact_id).await?; - - // We're including the bot's public key in Delta Chat - // so that the first message to the bot can directly be encrypted: - let public_key = SignedPublicKey::from_base64( - "xjMEZbfBlBYJKwYBBAHaRw8BAQdABpLWS2PUIGGo4pslVt4R8sylP5wZihmhf1DTDr3oCM\ - PNHDxzZWxmX3JlcG9ydGluZ0B0ZXN0cnVuLm9yZz7CiwQQFggAMwIZAQUCZbfBlAIbAwQLCQgHBhUI\ - CQoLAgMWAgEWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohD8dAQCQV7CoH6UP4PD+Nq\ - I4kW5tbbqdh2AnDROg60qotmLExAEAxDfd3QHAK9f8b9qQUbLmHIztCLxhEuVbWPBEYeVW0gvOOARl\ - t8GUEgorBgEEAZdVAQUBAQdAMBUhYoAAcI625vGZqnM5maPX4sGJ7qvJxPAFILPy6AcDAQgHwngEGB\ - YIACAFAmW3wZQCGwwWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohPwCAQCvzk1ObIkj\ - 2GqsuIfaULlgdnfdZY8LNary425CEfHZDQD5AblXVrlMO1frdlc/Vo9z3pEeCrfYdD7ITD3/OeVoiQ\ - 4=", - )?; - let mut peerstate = Peerstate::from_public_key( - SELF_REPORTING_BOT, - 0, - EncryptPreference::Mutual, - &public_key, - ); - let fingerprint = public_key.dc_fingerprint(); - peerstate.set_verified(public_key, fingerprint, "".to_string())?; - peerstate.save_to_db(&self.sql).await?; chat_id .set_protection(self, ProtectionStatus::Protected, time(), Some(contact_id)) .await?; diff --git a/src/decrypt.rs b/src/decrypt.rs index 2664ea701..8e242a57e 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -3,14 +3,9 @@ use std::collections::HashSet; use anyhow::Result; -use deltachat_contact_tools::addr_cmp; use mailparse::ParsedMail; -use crate::aheader::Aheader; -use crate::context::Context; -use crate::key::{DcKey, Fingerprint, SignedPublicKey, SignedSecretKey}; -use crate::log::info; -use crate::peerstate::Peerstate; +use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey}; use crate::pgp; /// Tries to decrypt a message, but only if it is structured as an Autocrypt message. @@ -144,83 +139,6 @@ pub(crate) fn validate_detached_signature<'a, 'b>( } } -/// Returns public keyring for `peerstate`. -pub(crate) fn keyring_from_peerstate(peerstate: Option<&Peerstate>) -> Vec { - let mut public_keyring_for_validate = Vec::new(); - if let Some(peerstate) = peerstate { - if let Some(key) = &peerstate.public_key { - public_keyring_for_validate.push(key.clone()); - } else if let Some(key) = &peerstate.gossip_key { - public_keyring_for_validate.push(key.clone()); - } - } - public_keyring_for_validate -} - -/// Applies Autocrypt header to Autocrypt peer state and saves it into the database. -/// -/// If we already know this fingerprint from another contact's peerstate, return that -/// peerstate in order to make AEAP work, but don't save it into the db yet. -/// -/// Returns updated peerstate. -pub(crate) async fn get_autocrypt_peerstate( - context: &Context, - from: &str, - autocrypt_header: Option<&Aheader>, - message_time: i64, - allow_aeap: bool, -) -> Result> { - let allow_change = !context.is_self_addr(from).await?; - let mut peerstate; - - // Apply Autocrypt header - if let Some(header) = autocrypt_header { - if allow_aeap { - // If we know this fingerprint from another addr, - // we may want to do a transition from this other addr - // (and keep its peerstate) - // For security reasons, for now, we only do a transition - // if the fingerprint is verified. - peerstate = Peerstate::from_verified_fingerprint_or_addr( - context, - &header.public_key.dc_fingerprint(), - from, - ) - .await?; - } else { - peerstate = Peerstate::from_addr(context, from).await?; - } - - if let Some(ref mut peerstate) = peerstate { - if addr_cmp(&peerstate.addr, from) { - if allow_change { - peerstate.apply_header(context, header, message_time); - peerstate.save_to_db(&context.sql).await?; - } else { - info!( - context, - "Refusing to update existing peerstate of {}", &peerstate.addr - ); - } - } - // If `peerstate.addr` and `from` differ, this means that - // someone is using the same key but a different addr, probably - // because they made an AEAP transition. - // But we don't know if that's legit until we checked the - // signatures, so wait until then with writing anything - // to the database. - } else { - let p = Peerstate::from_header(header, message_time); - p.save_to_db(&context.sql).await?; - peerstate = Some(p); - } - } else { - peerstate = Peerstate::from_addr(context, from).await?; - } - - Ok(peerstate) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/e2ee.rs b/src/e2ee.rs index f5bf44cc0..d4529bfaf 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -1,9 +1,8 @@ //! End-to-end encryption support. -use std::collections::BTreeSet; use std::io::Cursor; -use anyhow::{bail, Result}; +use anyhow::Result; use mail_builder::mime::MimePart; use num_traits::FromPrimitive; @@ -11,8 +10,6 @@ use crate::aheader::{Aheader, EncryptPreference}; use crate::config::Config; use crate::context::Context; use crate::key::{load_self_public_key, load_self_secret_key, SignedPublicKey}; -use crate::log::warn; -use crate::peerstate::Peerstate; use crate::pgp; #[derive(Debug)] @@ -43,93 +40,6 @@ impl EncryptHelper { Aheader::new(addr, pk, self.prefer_encrypt) } - /// Determines if we can and should encrypt. - pub(crate) async fn should_encrypt( - &self, - context: &Context, - peerstates: &[(Option, String)], - ) -> Result { - let is_chatmail = context.is_chatmail().await?; - for (peerstate, _addr) in peerstates { - if let Some(peerstate) = peerstate { - // For chatmail we ignore the encryption preference, - // because we can either send encrypted or not at all. - if is_chatmail || peerstate.prefer_encrypt != EncryptPreference::Reset { - continue; - } - } - return Ok(false); - } - Ok(true) - } - - /// Constructs a vector of public keys for given peerstates. - /// - /// In addition returns the set of recipient addresses - /// for which there is no key available. - /// - /// Returns an error if there are recipients - /// other than self, but no recipient keys are available. - pub(crate) fn encryption_keyring( - &self, - context: &Context, - verified: bool, - peerstates: &[(Option, String)], - ) -> Result<(Vec, BTreeSet)> { - // Encrypt to self unconditionally, - // even for a single-device setup. - let mut keyring = vec![self.public_key.clone()]; - let mut missing_key_addresses = BTreeSet::new(); - - if peerstates.is_empty() { - return Ok((keyring, missing_key_addresses)); - } - - let mut verifier_addresses: Vec<&str> = Vec::new(); - - for (peerstate, addr) in peerstates { - if let Some(peerstate) = peerstate { - if let Some(key) = peerstate.clone().take_key(verified) { - keyring.push(key); - verifier_addresses.push(addr); - } else { - warn!(context, "Encryption key for {addr} is missing."); - missing_key_addresses.insert(addr.clone()); - } - } else { - warn!(context, "Peerstate for {addr} is missing."); - missing_key_addresses.insert(addr.clone()); - } - } - - debug_assert!( - !keyring.is_empty(), - "At least our own key is in the keyring" - ); - if keyring.len() <= 1 { - bail!("No recipient keys are available, cannot encrypt"); - } - - // Encrypt to secondary verified keys - // if we also encrypt to the introducer ("verifier") of the key. - if verified { - for (peerstate, _addr) in peerstates { - if let Some(peerstate) = peerstate { - if let (Some(key), Some(verifier)) = ( - peerstate.secondary_verified_key.as_ref(), - peerstate.secondary_verifier.as_deref(), - ) { - if verifier_addresses.contains(&verifier) { - keyring.push(key.clone()); - } - } - } - } - } - - Ok((keyring, missing_key_addresses)) - } - /// Tries to encrypt the passed in `mail`. pub async fn encrypt( self, @@ -177,11 +87,9 @@ mod tests { use super::*; use crate::chat::send_text_msg; use crate::config::Config; - use crate::key::DcKey; - use crate::message::{Message, Viewtype}; - use crate::param::Param; + use crate::message::Message; use crate::receive_imf::receive_imf; - use crate::test_utils::{bob_keypair, TestContext, TestContextManager}; + use crate::test_utils::{TestContext, TestContextManager}; mod ensure_secret_key_exists { use super::*; @@ -223,130 +131,6 @@ Sent with my Delta Chat Messenger: https://delta.chat"; ); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_encrypted_no_autocrypt() -> anyhow::Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - let chat_alice = alice.create_email_chat(&bob).await.id; - let chat_bob = bob.create_email_chat(&alice).await.id; - - // Alice sends unencrypted message to Bob - let mut msg = Message::new(Viewtype::Text); - let sent = alice.send_msg(chat_alice, &mut msg).await; - - // Bob receives unencrypted message from Alice - let msg = bob.recv_msg(&sent).await; - assert!(!msg.get_showpadlock()); - - let peerstate_alice = Peerstate::from_addr(&bob.ctx, "alice@example.org") - .await? - .expect("no peerstate found in the database"); - assert_eq!(peerstate_alice.prefer_encrypt, EncryptPreference::Mutual); - - // Bob sends empty encrypted message to Alice - let mut msg = Message::new(Viewtype::Text); - let sent = bob.send_msg(chat_bob, &mut msg).await; - - // Alice receives an empty encrypted message from Bob. - // This is also a regression test for previously existing bug - // that resulted in no padlock on encrypted empty messages. - let msg = alice.recv_msg(&sent).await; - assert!(msg.get_showpadlock()); - - let peerstate_bob = Peerstate::from_addr(&alice.ctx, "bob@example.net") - .await? - .expect("no peerstate found in the database"); - assert_eq!(peerstate_bob.prefer_encrypt, EncryptPreference::Mutual); - - // Now Alice and Bob have established keys. - - // Alice sends encrypted message without Autocrypt header. - let mut msg = Message::new(Viewtype::Text); - msg.param.set_int(Param::SkipAutocrypt, 1); - let sent = alice.send_msg(chat_alice, &mut msg).await; - - let msg = bob.recv_msg(&sent).await; - assert!(msg.get_showpadlock()); - let peerstate_alice = Peerstate::from_addr(&bob.ctx, "alice@example.org") - .await? - .expect("no peerstate found in the database"); - assert_eq!(peerstate_alice.prefer_encrypt, EncryptPreference::Mutual); - - // Alice sends plaintext message with Autocrypt header. - let mut msg = Message::new(Viewtype::Text); - msg.force_plaintext(); - let sent = alice.send_msg(chat_alice, &mut msg).await; - - let msg = bob.recv_msg(&sent).await; - assert!(!msg.get_showpadlock()); - let peerstate_alice = Peerstate::from_addr(&bob.ctx, "alice@example.org") - .await? - .expect("no peerstate found in the database"); - assert_eq!(peerstate_alice.prefer_encrypt, EncryptPreference::Mutual); - - // Alice sends plaintext message without Autocrypt header. - let mut msg = Message::new(Viewtype::Text); - msg.force_plaintext(); - msg.param.set_int(Param::SkipAutocrypt, 1); - let sent = alice.send_msg(chat_alice, &mut msg).await; - - let msg = bob.recv_msg(&sent).await; - assert!(!msg.get_showpadlock()); - let peerstate_alice = Peerstate::from_addr(&bob.ctx, "alice@example.org") - .await? - .expect("no peerstate found in the database"); - assert_eq!(peerstate_alice.prefer_encrypt, EncryptPreference::Reset); - - Ok(()) - } - - fn new_peerstates(prefer_encrypt: EncryptPreference) -> Vec<(Option, String)> { - let addr = "bob@foo.bar"; - let pub_key = bob_keypair().public; - let peerstate = Peerstate { - addr: addr.into(), - last_seen: 13, - last_seen_autocrypt: 14, - prefer_encrypt, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: Some(pub_key.clone()), - gossip_timestamp: 15, - gossip_key_fingerprint: Some(pub_key.dc_fingerprint()), - verified_key: Some(pub_key.clone()), - verified_key_fingerprint: Some(pub_key.dc_fingerprint()), - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - vec![(Some(peerstate), addr.to_string())] - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_should_encrypt() -> Result<()> { - let t = TestContext::new_alice().await; - let encrypt_helper = EncryptHelper::new(&t).await.unwrap(); - - let ps = new_peerstates(EncryptPreference::NoPreference); - assert!(encrypt_helper.should_encrypt(&t, &ps).await?); - - let ps = new_peerstates(EncryptPreference::Reset); - assert!(!encrypt_helper.should_encrypt(&t, &ps).await?); - - let ps = new_peerstates(EncryptPreference::Mutual); - assert!(encrypt_helper.should_encrypt(&t, &ps).await?); - - // test with missing peerstate - let ps = vec![(None, "bob@foo.bar".to_string())]; - assert!(!encrypt_helper.should_encrypt(&t, &ps).await?); - 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/events/chatlist_events.rs b/src/events/chatlist_events.rs index 4b612f3ee..57d096825 100644 --- a/src/events/chatlist_events.rs +++ b/src/events/chatlist_events.rs @@ -247,8 +247,7 @@ mod test_chatlist_events { bob.evtracker.clear_events(); // set name - let addr = alice_on_bob.get_addr(); - Contact::create(&bob, "Alice2", addr).await?; + alice_on_bob.id.set_name(&bob, "Alice2").await?; assert!(bob.add_or_lookup_contact(&alice).await.get_display_name() == "Alice2"); wait_for_chatlist_all_items(&bob).await; diff --git a/src/headerdef.rs b/src/headerdef.rs index 6cfd61af6..330a4d9ba 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -75,6 +75,12 @@ pub enum HeaderDef { /// for members listed in the `Chat-Group-Past-Members` field. ChatGroupMemberTimestamps, + /// Space-separated PGP key fingerprints + /// of group members listed in the `To` field + /// followed by fingerprints + /// of past members listed in the `Chat-Group-Past-Members` field. + ChatGroupMemberFpr, + /// Duration of the attached media file. ChatDuration, diff --git a/src/imap.rs b/src/imap.rs index 53198f55c..5119c61d1 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -1884,7 +1884,7 @@ async fn should_move_out_of_spam( }; // No chat found. let (from_id, blocked_contact, _origin) = - match from_field_to_contact_id(context, &from, true) + match from_field_to_contact_id(context, &from, None, true, true) .await .context("from_field_to_contact_id")? { @@ -2242,7 +2242,7 @@ pub(crate) async fn prefetch_should_download( None => return Ok(false), }; let (_from_id, blocked_contact, origin) = - match from_field_to_contact_id(context, &from, true).await? { + match from_field_to_contact_id(context, &from, None, true, true).await? { Some(res) => res, None => return Ok(false), }; diff --git a/src/imex/key_transfer.rs b/src/imex/key_transfer.rs index d7726e8d8..273508b99 100644 --- a/src/imex/key_transfer.rs +++ b/src/imex/key_transfer.rs @@ -290,10 +290,12 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_key_transfer() -> Result<()> { - let alice = TestContext::new_alice().await; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + tcm.section("Alice sends Autocrypt setup message"); alice.set_config(Config::BccSelf, Some("0")).await?; - let setup_code = initiate_key_transfer(&alice).await?; + let setup_code = initiate_key_transfer(alice).await?; // Test that sending Autocrypt Setup Message enables `bcc_self`. assert_eq!(alice.get_config_bool(Config::BccSelf).await?, true); @@ -301,26 +303,21 @@ mod tests { // Get Autocrypt Setup Message. let sent = alice.pop_sent_msg().await; - // Alice sets up a second device. - let alice2 = TestContext::new().await; + tcm.section("Alice sets up a second device"); + let alice2 = &tcm.unconfigured().await; alice2.set_name("alice2"); alice2.configure_addr("alice@example.org").await; alice2.recv_msg(&sent).await; let msg = alice2.get_last_msg().await; assert!(msg.is_setupmessage()); - assert_eq!( - crate::key::load_self_secret_keyring(&alice2).await?.len(), - 0 - ); + assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 0); // Transfer the key. + tcm.section("Alice imports a key from Autocrypt Setup Message"); alice2.set_config(Config::BccSelf, Some("0")).await?; - continue_key_transfer(&alice2, msg.id, &setup_code).await?; + continue_key_transfer(alice2, msg.id, &setup_code).await?; assert_eq!(alice2.get_config_bool(Config::BccSelf).await?, true); - assert_eq!( - crate::key::load_self_secret_keyring(&alice2).await?.len(), - 1 - ); + assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 1); // Alice sends a message to self from the new device. let sent = alice2.send_text(msg.chat_id, "Test").await; diff --git a/src/key.rs b/src/key.rs index dc44f0563..c39246033 100644 --- a/src/key.rs +++ b/src/key.rs @@ -129,8 +129,11 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone { fn key_id(&self) -> KeyId; } -pub(crate) async fn load_self_public_key(context: &Context) -> Result { - let public_key = context +/// Attempts to load own public key. +/// +/// Returns `None` if no key is generated yet. +pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result> { + let Some(public_key_bytes) = context .sql .query_row_optional( "SELECT public_key @@ -142,9 +145,20 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result SignedPublicKey::from_slice(&bytes), + .await? + else { + return Ok(None); + }; + let public_key = SignedPublicKey::from_slice(&public_key_bytes)?; + Ok(Some(public_key)) +} + +/// Loads own public key. +/// +/// If no key is generated yet, generates a new one. +pub(crate) async fn load_self_public_key(context: &Context) -> Result { + match load_self_public_key_opt(context).await? { + Some(public_key) => Ok(public_key), None => { let keypair = generate_keypair(context).await?; Ok(keypair.public) @@ -171,6 +185,38 @@ pub(crate) async fn load_self_public_keyring(context: &Context) -> Result Result<&str> { + if let Some(fp) = context.self_fingerprint.get() { + Ok(fp) + } else { + let fp = load_self_public_key(context).await?.dc_fingerprint().hex(); + Ok(context.self_fingerprint.get_or_init(|| fp)) + } +} + +/// Returns own public key fingerprint in (not human-readable) hex representation. +/// This is the fingerprint format that is used in the database. +/// +/// Returns `None` if no key is generated yet. +/// +/// For performance reasons, the fingerprint is cached after the first invocation. +pub(crate) async fn self_fingerprint_opt(context: &Context) -> Result> { + if let Some(fp) = context.self_fingerprint.get() { + Ok(Some(fp)) + } else if let Some(key) = load_self_public_key_opt(context).await? { + let fp = key.dc_fingerprint().hex(); + Ok(Some(context.self_fingerprint.get_or_init(|| fp))) + } else { + Ok(None) + } +} + pub(crate) async fn load_self_secret_key(context: &Context) -> Result { let private_key = context .sql diff --git a/src/lib.rs b/src/lib.rs index 9e9cc49cc..3c6402cbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,7 +74,6 @@ mod mimefactory; pub mod mimeparser; pub mod oauth2; mod param; -pub mod peerstate; mod pgp; pub mod provider; pub mod qr; diff --git a/src/log.rs b/src/log.rs index 2a81067fa..ab6b15494 100644 --- a/src/log.rs +++ b/src/log.rs @@ -68,6 +68,16 @@ impl Context { let last_error = &*self.last_error.read(); last_error.clone() } + + pub fn set_migration_error(&self, error: &str) { + let mut migration_error = self.migration_error.write(); + *migration_error = Some(error.to_string()); + } + + pub fn get_migration_error(&self) -> Option { + let migration_error = &*self.migration_error.read(); + migration_error.clone() + } } pub trait LogExt diff --git a/src/message.rs b/src/message.rs index 0f1e9da1f..91351dd6f 100644 --- a/src/message.rs +++ b/src/message.rs @@ -835,6 +835,7 @@ impl Message { /// Returns true if padlock indicating message encryption should be displayed in the UI. pub fn get_showpadlock(&self) -> bool { self.param.get_int(Param::GuaranteeE2ee).unwrap_or_default() != 0 + || self.from_id == ContactId::DEVICE } /// Returns true if message is auto-generated. diff --git a/src/message/message_tests.rs b/src/message/message_tests.rs index 7c4bb0734..5846012bc 100644 --- a/src/message/message_tests.rs +++ b/src/message/message_tests.rs @@ -1,10 +1,7 @@ use num_traits::FromPrimitive; use super::*; -use crate::chat::{ - self, add_contact_to_chat, forward_msgs, marknoticed_chat, save_msgs, send_text_msg, ChatItem, - ProtectionStatus, -}; +use crate::chat::{self, forward_msgs, marknoticed_chat, save_msgs, send_text_msg, ChatItem}; use crate::chatlist::Chatlist; use crate::config::Config; use crate::reaction::send_reaction; @@ -106,7 +103,7 @@ async fn test_create_webrtc_instance_noroom() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_width_height() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; // test that get_width() and get_height() are returning some dimensions for images; // (as the device-chat contains a welcome-images, we check that) @@ -183,6 +180,8 @@ async fn test_no_quote() { assert!(msg.quoted_message(bob).await.unwrap().is_none()); } +/// Tests that quote of encrypted message +/// cannot be sent unencrypted. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_unencrypted_quote_encrypted_message() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -190,40 +189,26 @@ async fn test_unencrypted_quote_encrypted_message() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_group = alice - .create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob]) + tcm.section("Bob sends encrypted message to Alice"); + let alice_chat = alice.create_chat(bob).await; + let sent = alice + .send_text(alice_chat.id, "Hi! This is encrypted.") .await; - let sent = alice.send_text(alice_group, "Hi! I created a group").await; + let bob_received_message = bob.recv_msg(&sent).await; + assert_eq!(bob_received_message.get_showpadlock(), true); - let bob_group = bob_received_message.chat_id; - bob_group.accept(bob).await?; - let sent = bob.send_text(bob_group, "Encrypted message").await; - let alice_received_message = alice.recv_msg(&sent).await; - assert!(alice_received_message.get_showpadlock()); + // Bob quotes encrypted message in unencrypted chat. + let bob_email_chat = bob.create_email_chat(alice).await; + let mut msg = Message::new_text("I am sending an unencrypted reply.".to_string()); + msg.set_quote(bob, Some(&bob_received_message)).await?; + chat::send_msg(bob, bob_email_chat.id, &mut msg).await?; - // Alice adds contact without key so chat becomes unencrypted. - let alice_flubby_contact_id = Contact::create(alice, "Flubby", "flubby@example.org").await?; - add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?; - - // Alice quotes encrypted message in unencrypted chat. - let mut msg = Message::new_text("unencrypted".to_string()); - msg.set_quote(alice, Some(&alice_received_message)).await?; - chat::send_msg(alice, alice_group, &mut msg).await?; - - let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await; - assert_eq!(bob_received_message.quoted_text().unwrap(), "..."); - assert_eq!(bob_received_message.get_showpadlock(), false); - - // Alice replaces a quote of encrypted message with a quote of unencrypted one. - let mut msg1 = Message::new(Viewtype::Text); - msg1.set_quote(alice, Some(&alice_received_message)).await?; - msg1.set_quote(alice, Some(&msg)).await?; - chat::send_msg(alice, alice_group, &mut msg1).await?; - - let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await; - assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted"); - assert_eq!(bob_received_message.get_showpadlock(), false); + // Alice receives unencrypted message, + // but the quote of encrypted message is replaced with "...". + let alice_received_message = alice.recv_msg(&bob.pop_sent_msg().await).await; + assert_eq!(alice_received_message.quoted_text().unwrap(), "..."); + assert_eq!(alice_received_message.get_showpadlock(), false); Ok(()) } @@ -424,12 +409,16 @@ async fn test_markseen_not_downloaded_msg() -> Result<()> { let alice = &tcm.alice().await; alice.set_config(Config::DownloadLimit, Some("1")).await?; let bob = &tcm.bob().await; - let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id; + let bob_chat_id = bob.create_chat(alice).await.id; + alice.create_chat(bob).await; // Make sure the chat is accepted. + tcm.section("Bob sends a large message to Alice"); let file_bytes = include_bytes!("../../test-data/image/screenshot.png"); let mut msg = Message::new(Viewtype::Image); msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?; let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await; + + tcm.section("Alice receives a large message from Bob"); let msg = alice.recv_msg(&sent_msg).await; assert_eq!(msg.download_state, DownloadState::Available); assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default()); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 14639b918..d301b2a53 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1,6 +1,6 @@ //! # MIME message production. -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use std::io::Cursor; use std::path::Path; @@ -23,14 +23,14 @@ use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::e2ee::EncryptHelper; use crate::ephemeral::Timer as EphemeralTimer; -use crate::key::DcKey; +use crate::key::self_fingerprint; +use crate::key::{DcKey, SignedPublicKey}; use crate::location; use crate::log::{info, warn}; use crate::message::{self, Message, MsgId, Viewtype}; use crate::mimeparser::{is_hidden, SystemMessage}; use crate::param::Param; use crate::peer_channels::create_iroh_header; -use crate::peerstate::Peerstate; use crate::simplify::escape_message_footer_marks; use crate::stock_str; use crate::tools::{ @@ -88,6 +88,13 @@ pub struct MimeFactory { /// but `MimeFactory` is not responsible for this. recipients: Vec, + /// Vector of pairs of recipient + /// addresses and OpenPGP keys + /// to use for encryption. + /// + /// `None` if the message is not encrypted. + encryption_keys: Option>, + /// Vector of pairs of recipient name and address that goes into the `To` field. /// /// The list of actual message recipient addresses may be different, @@ -99,11 +106,18 @@ pub struct MimeFactory { /// Vector of pairs of past group member names and addresses. past_members: Vec<(String, String)>, + /// Fingerprints of the members in the same order as in the `to` + /// followed by `past_members`. + /// + /// If this is not empty, its length + /// should be the sum of `to` and `past_members` length. + member_fingerprints: Vec, + /// Timestamps of the members in the same order as in the `to` /// followed by `past_members`. /// /// If this is not empty, its length - /// should be the sum of `recipients` and `past_members` length. + /// should be the sum of `to` and `past_members` length. member_timestamps: Vec, timestamp: i64, @@ -185,12 +199,24 @@ impl MimeFactory { let mut recipients = Vec::new(); let mut to = Vec::new(); let mut past_members = Vec::new(); + let mut member_fingerprints = Vec::new(); let mut member_timestamps = Vec::new(); let mut recipient_ids = HashSet::new(); let mut req_mdn = false; + let encryption_keys; + + let self_fingerprint = self_fingerprint(context).await?; + if chat.is_self_talk() { to.push((from_displayname.to_string(), from_addr.to_string())); + + encryption_keys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) { + None + } else { + // Encrypt, but only to self. + Some(Vec::new()) + }; } else if chat.is_mailing_list() { let list_post = chat .param @@ -198,6 +224,9 @@ impl MimeFactory { .context("Can't write to mailinglist without ListPost param")?; to.push(("".to_string(), list_post.to_string())); recipients.push(list_post.to_string()); + + // Do not encrypt messages to mailing lists. + encryption_keys = None; } else { let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup { msg.param.get(Param::Arg) @@ -205,27 +234,59 @@ impl MimeFactory { None }; + let is_encrypted = if msg + .param + .get_bool(Param::ForcePlaintext) + .unwrap_or_default() + { + false + } else { + msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default() + || chat.is_encrypted(context).await? + }; + + let mut keys = Vec::new(); + let mut missing_key_addresses = BTreeSet::new(); context .sql .query_map( - "SELECT c.authname, c.addr, c.id, cc.add_timestamp, cc.remove_timestamp + "SELECT + c.authname, + c.addr, + c.fingerprint, + c.id, + cc.add_timestamp, + cc.remove_timestamp, + k.public_key FROM chats_contacts cc LEFT JOIN contacts c ON cc.contact_id=c.id - WHERE cc.chat_id=? AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))", + LEFT JOIN public_keys k ON k.fingerprint=c.fingerprint + WHERE cc.chat_id=? + AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))", (msg.chat_id, chat.typ == Chattype::Group), |row| { let authname: String = row.get(0)?; let addr: String = row.get(1)?; - let id: ContactId = row.get(2)?; - let add_timestamp: i64 = row.get(3)?; - let remove_timestamp: i64 = row.get(4)?; - Ok((authname, addr, id, add_timestamp, remove_timestamp)) + let fingerprint: String = row.get(2)?; + let id: ContactId = row.get(3)?; + let add_timestamp: i64 = row.get(4)?; + let remove_timestamp: i64 = row.get(5)?; + let public_key_bytes_opt: Option> = row.get(6)?; + Ok((authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt)) }, |rows| { let mut past_member_timestamps = Vec::new(); + let mut past_member_fingerprints = Vec::new(); for row in rows { - let (authname, addr, id, add_timestamp, remove_timestamp) = row?; + let (authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt) = row?; + + let public_key_opt = if let Some(public_key_bytes) = &public_key_bytes_opt { + Some(SignedPublicKey::from_slice(public_key_bytes)?) + } else { + None + }; + let addr = if id == ContactId::SELF { from_addr.to_string() } else { @@ -237,13 +298,34 @@ impl MimeFactory { }; if add_timestamp >= remove_timestamp { if !recipients_contain_addr(&to, &addr) { - recipients.push(addr.clone()); + if id != ContactId::SELF { + recipients.push(addr.clone()); + } if !undisclosed_recipients { - to.push((name, addr)); + to.push((name, addr.clone())); + + if is_encrypted { + if !fingerprint.is_empty() { + member_fingerprints.push(fingerprint); + } else if id == ContactId::SELF { + member_fingerprints.push(self_fingerprint.to_string()); + } else { + debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too"); + } + } member_timestamps.push(add_timestamp); } } recipient_ids.insert(id); + + if let Some(public_key) = public_key_opt { + keys.push((addr.clone(), public_key)) + } else if id != ContactId::SELF { + missing_key_addresses.insert(addr.clone()); + if is_encrypted { + warn!(context, "Missing key for {addr}"); + } + } } else if remove_timestamp.saturating_add(60 * 24 * 3600) > now { // Row is a tombstone, // member is not actually part of the group. @@ -253,27 +335,57 @@ impl MimeFactory { // This is a "member removed" message, // we need to notify removed member // that it was removed. - recipients.push(addr.clone()); + if id != ContactId::SELF { + recipients.push(addr.clone()); + } + + if let Some(public_key) = public_key_opt { + keys.push((addr.clone(), public_key)) + } else if id != ContactId::SELF { + missing_key_addresses.insert(addr.clone()); + if is_encrypted { + warn!(context, "Missing key for {addr}"); + } + } } } if !undisclosed_recipients { - past_members.push((name, addr)); + past_members.push((name, addr.clone())); past_member_timestamps.push(remove_timestamp); + + if is_encrypted { + if !fingerprint.is_empty() { + past_member_fingerprints.push(fingerprint); + } else if id == ContactId::SELF { + // It's fine to have self in past members + // if we are leaving the group. + past_member_fingerprints.push(self_fingerprint.to_string()); + } else { + debug_assert!(past_member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too"); + } + } } } } } debug_assert!(member_timestamps.len() >= to.len()); + debug_assert!(member_fingerprints.is_empty() || member_fingerprints.len() >= to.len()); if to.len() > 1 { if let Some(position) = to.iter().position(|(_, x)| x == &from_addr) { to.remove(position); member_timestamps.remove(position); + if is_encrypted { + member_fingerprints.remove(position); + } } } member_timestamps.extend(past_member_timestamps); + if is_encrypted { + member_fingerprints.extend(past_member_fingerprints); + } Ok(()) }, ) @@ -287,7 +399,26 @@ impl MimeFactory { { req_mdn = true; } + + encryption_keys = if !is_encrypted { + None + } else { + if keys.is_empty() && !recipients.is_empty() { + bail!( + "No recipient keys are available, cannot encrypt to {:?}.", + recipients + ); + } + + // Remove recipients for which the key is missing. + if !missing_key_addresses.is_empty() { + recipients.retain(|addr| !missing_key_addresses.contains(addr)); + } + + Some(keys) + }; } + let (in_reply_to, references) = context .sql .query_row( @@ -320,14 +451,17 @@ impl MimeFactory { member_timestamps.is_empty() || to.len() + past_members.len() == member_timestamps.len() ); + let factory = MimeFactory { from_addr, from_displayname, sender_displayname, selfstatus, recipients, + encryption_keys, to, past_members, + member_fingerprints, member_timestamps, timestamp: msg.timestamp_sort, loaded: Loaded::Message { msg, chat }, @@ -351,14 +485,27 @@ impl MimeFactory { let from_addr = context.get_primary_self_addr().await?; let timestamp = create_smeared_timestamp(context); + let addr = contact.get_addr().to_string(); + let encryption_keys = if contact.is_key_contact() { + if let Some(key) = contact.public_key(context).await? { + Some(vec![(addr.clone(), key)]) + } else { + Some(Vec::new()) + } + } else { + None + }; + let res = MimeFactory { from_addr, from_displayname: "".to_string(), sender_displayname: None, selfstatus: "".to_string(), - recipients: vec![contact.get_addr().to_string()], + recipients: vec![addr], + encryption_keys, to: vec![("".to_string(), contact.get_addr().to_string())], past_members: vec![], + member_fingerprints: vec![], member_timestamps: vec![], timestamp, loaded: Loaded::Mdn { @@ -376,57 +523,6 @@ impl MimeFactory { Ok(res) } - async fn peerstates_for_recipients( - &self, - context: &Context, - ) -> Result, String)>> { - let self_addr = context.get_primary_self_addr().await?; - - let mut res = Vec::new(); - for addr in self.recipients.iter().filter(|&addr| *addr != self_addr) { - res.push((Peerstate::from_addr(context, addr).await?, addr.clone())); - } - - Ok(res) - } - - fn is_e2ee_guaranteed(&self) -> bool { - match &self.loaded { - Loaded::Message { chat, msg } => { - !msg.param - .get_bool(Param::ForcePlaintext) - .unwrap_or_default() - && (chat.is_protected() - || msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default()) - } - Loaded::Mdn { .. } => false, - } - } - - fn verified(&self) -> bool { - match &self.loaded { - Loaded::Message { chat, msg } => { - chat.is_self_talk() || - // Securejoin messages are supposed to verify a key. - // In order to do this, it is necessary that they can be sent - // to a key that is not yet verified. - // This has to work independently of whether the chat is protected right now. - chat.is_protected() && msg.get_info_type() != SystemMessage::SecurejoinMessage - } - Loaded::Mdn { .. } => false, - } - } - - fn should_force_plaintext(&self) -> bool { - match &self.loaded { - Loaded::Message { msg, .. } => msg - .param - .get_bool(Param::ForcePlaintext) - .unwrap_or_default(), - Loaded::Mdn { .. } => false, - } - } - fn should_skip_autocrypt(&self) -> bool { match &self.loaded { Loaded::Message { msg, .. } => { @@ -602,21 +698,35 @@ impl MimeFactory { } if let Loaded::Message { chat, .. } = &self.loaded { - if chat.typ == Chattype::Group - && !self.member_timestamps.is_empty() - && !chat.member_list_is_stale(context).await? - { - headers.push(( - "Chat-Group-Member-Timestamps", - mail_builder::headers::raw::Raw::new( - self.member_timestamps - .iter() - .map(|ts| ts.to_string()) - .collect::>() - .join(" "), - ) - .into(), - )); + if chat.typ == Chattype::Group { + if !self.member_timestamps.is_empty() && !chat.member_list_is_stale(context).await? + { + headers.push(( + "Chat-Group-Member-Timestamps", + mail_builder::headers::raw::Raw::new( + self.member_timestamps + .iter() + .map(|ts| ts.to_string()) + .collect::>() + .join(" "), + ) + .into(), + )); + } + + if !self.member_fingerprints.is_empty() { + headers.push(( + "Chat-Group-Member-Fpr", + mail_builder::headers::raw::Raw::new( + self.member_fingerprints + .iter() + .map(|fp| fp.to_string()) + .collect::>() + .join(" "), + ) + .into(), + )); + } } } @@ -727,10 +837,8 @@ impl MimeFactory { )); } - let verified = self.verified(); let grpimage = self.grpimage(); let skip_autocrypt = self.should_skip_autocrypt(); - let e2ee_guaranteed = self.is_e2ee_guaranteed(); let encrypt_helper = EncryptHelper::new(context).await?; if !skip_autocrypt { @@ -742,6 +850,8 @@ impl MimeFactory { )); } + let is_encrypted = self.encryption_keys.is_some(); + // Add ephemeral timer for non-MDN messages. // For MDNs it does not matter because they are not visible // and ignored by the receiver. @@ -755,9 +865,6 @@ impl MimeFactory { } } - let peerstates = self.peerstates_for_recipients(context).await?; - let is_encrypted = !self.should_force_plaintext() - && (e2ee_guaranteed || encrypt_helper.should_encrypt(context, &peerstates).await?); let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { msg.param.get_cmd() == SystemMessage::SecurejoinMessage } else { @@ -904,7 +1011,7 @@ impl MimeFactory { } } - let outer_message = if is_encrypted { + let outer_message = if let Some(encryption_keys) = self.encryption_keys { // Store protected headers in the inner message. let message = protected_headers .into_iter() @@ -921,7 +1028,7 @@ impl MimeFactory { // Add gossip headers in chats with multiple recipients let multiple_recipients = - peerstates.len() > 1 || context.get_config_bool(Config::BccSelf).await?; + encryption_keys.len() > 1 || context.get_config_bool(Config::BccSelf).await?; let gossip_period = context.get_config_i64(Config::GossipPeriod).await?; let now = time(); @@ -929,11 +1036,7 @@ impl MimeFactory { match &self.loaded { Loaded::Message { chat, msg } => { if chat.typ != Chattype::Broadcast { - for peerstate in peerstates.iter().filter_map(|(state, _)| state.as_ref()) { - let Some(key) = peerstate.peek_key(verified) else { - continue; - }; - + for (addr, key) in &encryption_keys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup @@ -965,7 +1068,7 @@ impl MimeFactory { } let header = Aheader::new( - peerstate.addr.clone(), + addr.clone(), key.clone(), // Autocrypt 1.1.0 specification says that // `prefer-encrypt` attribute SHOULD NOT be included. @@ -1015,8 +1118,10 @@ impl MimeFactory { Loaded::Mdn { .. } => true, }; - let (encryption_keyring, missing_key_addresses) = - encrypt_helper.encryption_keyring(context, verified, &peerstates)?; + // Encrypt to self unconditionally, + // even for a single-device setup. + let mut encryption_keyring = vec![encrypt_helper.public_key.clone()]; + encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone())); // XXX: additional newline is needed // to pass filtermail at @@ -1026,12 +1131,6 @@ impl MimeFactory { .await? + "\n"; - // Remove recipients for which the key is missing. - if !missing_key_addresses.is_empty() { - self.recipients - .retain(|addr| !missing_key_addresses.contains(addr)); - } - // Set the appropriate Content-Type for the outer message MimePart::new( "multipart/encrypted; protocol=\"application/pgp-encrypted\"", @@ -1257,6 +1356,8 @@ impl MimeFactory { } } SystemMessage::MemberAddedToGroup => { + // TODO: lookup the contact by ID rather than email address. + // We are adding key-contacts, the cannot be looked up by address. let email_to_add = msg.param.get(Param::Arg).unwrap_or_default(); placeholdertext = Some(stock_str::msg_add_member_remote(context, email_to_add).await); @@ -1561,7 +1662,7 @@ impl MimeFactory { // we do not piggyback sync-files to other self-sent-messages // to not risk files becoming too larger and being skipped by download-on-demand. - if command == SystemMessage::MultiDeviceSync && self.is_e2ee_guaranteed() { + if command == SystemMessage::MultiDeviceSync { let json = msg.param.get(Param::Arg).unwrap_or_default(); let ids = msg.param.get(Param::Arg2).unwrap_or_default(); parts.push(context.build_sync_part(json.to_string())); diff --git a/src/mimefactory/mimefactory_tests.rs b/src/mimefactory/mimefactory_tests.rs index 22306ddeb..97c1ee788 100644 --- a/src/mimefactory/mimefactory_tests.rs +++ b/src/mimefactory/mimefactory_tests.rs @@ -339,39 +339,31 @@ async fn test_subject_in_group() -> Result<()> { } // 6. Test that in a group, replies also take the quoted message's subject, while non-replies use the group title as subject - let t = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - let group_id = chat::create_group_chat(&t, chat::ProtectionStatus::Unprotected, "groupname") // TODO encodings, ä + let mut tcm = TestContextManager::new(); + let t = tcm.alice().await; + let bob = tcm.bob().await; + let group_id = chat::create_group_chat(&t, chat::ProtectionStatus::Unprotected, "groupname") .await .unwrap(); let bob_contact_id = t.add_or_lookup_contact_id(&bob).await; chat::add_contact_to_chat(&t, group_id, bob_contact_id).await?; - let subject = send_msg_get_subject(&t, group_id, None).await?; - assert_eq!(subject, "groupname"); + let sent_message = t.send_text(group_id, "Hello!").await; + let bob_received_message = bob.recv_msg(&sent_message).await; + let bob_group_id = bob_received_message.chat_id; + bob_group_id.accept(&bob).await.unwrap(); + assert_eq!(get_subject(&t, sent_message).await?, "groupname"); let subject = send_msg_get_subject(&t, group_id, None).await?; assert_eq!(subject, "Re: groupname"); - receive_imf( - &t, - format!( - "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: bob@example.com\n\ - To: alice@example.org\n\ - Subject: Different subject\n\ - In-Reply-To: {}\n\ - Message-ID: <2893@example.com>\n\ - Date: Sun, 22 Mar 2020 22:37:56 +0000\n\ - \n\ - hello\n", - t.get_last_msg().await.rfc724_mid - ) - .as_bytes(), - false, - ) - .await?; - let message_from_bob = t.get_last_msg().await; + let subject = send_msg_get_subject(&t, group_id, None).await?; + assert_eq!(subject, "Re: groupname"); + + let mut msg = Message::new(Viewtype::Text); + msg.set_subject("Different subject".to_string()); + let bob_sent_msg = bob.send_msg(bob_group_id, &mut msg).await; + let message_from_bob = t.recv_msg(&bob_sent_msg).await; let subject = send_msg_get_subject(&t, group_id, None).await?; assert_eq!(subject, "Re: groupname"); @@ -623,43 +615,6 @@ async fn test_selfavatar_unencrypted() -> anyhow::Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_group_avatar_unencrypted() -> anyhow::Result<()> { - let t = &TestContext::new_alice().await; - let group_id = chat::create_group_chat(t, chat::ProtectionStatus::Unprotected, "Group") - .await - .unwrap(); - let bob = Contact::create(t, "", "bob@example.org").await?; - chat::add_contact_to_chat(t, group_id, bob).await?; - - let file = t.dir.path().join("avatar.png"); - let bytes = include_bytes!("../../test-data/image/avatar64x64.png"); - tokio::fs::write(&file, bytes).await?; - chat::set_chat_profile_image(t, group_id, file.to_str().unwrap()).await?; - - // Send message to bob: that should get multipart/mixed because of the avatar moved to inner header. - let mut msg = Message::new_text("this is the text!".to_string()); - let sent_msg = t.send_msg(group_id, &mut msg).await; - let mut payload = sent_msg.payload().splitn(3, "\r\n\r\n"); - - let outer = payload.next().unwrap(); - let inner = payload.next().unwrap(); - let body = payload.next().unwrap(); - - assert_eq!(outer.match_indices("multipart/mixed").count(), 1); - assert_eq!(outer.match_indices("Message-ID:").count(), 1); - assert_eq!(outer.match_indices("Subject:").count(), 1); - assert_eq!(outer.match_indices("Autocrypt:").count(), 1); - assert_eq!(outer.match_indices("Chat-Group-Avatar:").count(), 0); - - assert_eq!(inner.match_indices("text/plain").count(), 1); - assert_eq!(inner.match_indices("Message-ID:").count(), 1); - assert_eq!(inner.match_indices("Chat-Group-Avatar:").count(), 1); - - assert_eq!(body.match_indices("this is the text!").count(), 1); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_selfavatar_unencrypted_signed() { // create chat with bob, set selfavatar diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 92728c463..21600aa07 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -13,7 +13,7 @@ use format_flowed::unformat_flowed; use mailparse::{addrparse_header, DispositionType, MailHeader, MailHeaderMap, SingleInfo}; use mime::Mime; -use crate::aheader::{Aheader, EncryptPreference}; +use crate::aheader::Aheader; use crate::authres::handle_authres; use crate::blob::BlobObject; use crate::chat::ChatId; @@ -21,10 +21,7 @@ use crate::config::Config; use crate::constants; use crate::contact::ContactId; use crate::context::Context; -use crate::decrypt::{ - get_autocrypt_peerstate, get_encrypted_mime, keyring_from_peerstate, try_decrypt, - validate_detached_signature, -}; +use crate::decrypt::{try_decrypt, validate_detached_signature}; use crate::dehtml::dehtml; use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; @@ -32,7 +29,6 @@ use crate::key::{self, load_self_secret_keyring, DcKey, Fingerprint, SignedPubli use crate::log::{error, info, warn}; use crate::message::{self, get_vcard_summary, set_msg_failed, Message, MsgId, Viewtype}; use crate::param::{Param, Params}; -use crate::peerstate::Peerstate; use crate::simplify::{simplify, SimplifiedText}; use crate::sync::SyncItems; use crate::tools::{ @@ -72,17 +68,12 @@ pub(crate) struct MimeMessage { /// `From:` address. pub from: SingleInfo, - /// Whether the From address was repeated in the signed part - /// (and we know that the signer intended to send from this address) - pub from_is_signed: bool, /// Whether the message is incoming or outgoing (self-sent). pub incoming: bool, /// The List-Post address is only set for mailing lists. Users can send /// messages to this address to post them to the list. pub list_post: Option, pub chat_disposition_notification_to: Option, - pub autocrypt_header: Option, - pub peerstate: Option, pub decrypting_failed: bool, /// Set of valid signature fingerprints if a message is an @@ -91,11 +82,16 @@ pub(crate) struct MimeMessage { /// If a message is not encrypted or the signature is not valid, /// this set is empty. pub signatures: HashSet, - /// The mail recipient addresses for which gossip headers were applied - /// and their respective gossiped keys, - /// regardless of whether they modified any peerstates. + + /// The addresses for which there was a gossip header + /// and their respective gossiped keys. pub gossiped_keys: HashMap, + /// Fingerprint of the key in the Autocrypt header. + /// + /// It is not verified that the sender can use this key. + pub autocrypt_fingerprint: Option, + /// True if the message is a forwarded message. pub is_forwarded: bool, pub is_system_message: SystemMessage, @@ -118,8 +114,7 @@ pub(crate) struct MimeMessage { /// MIME message in this case. pub is_mime_modified: bool, - /// Decrypted, raw MIME structure. Nonempty iff `is_mime_modified` and the message was actually - /// encrypted. + /// Decrypted raw MIME structure. pub decoded_data: Vec, /// Hop info for debugging. @@ -260,7 +255,7 @@ impl MimeMessage { ); headers.retain(|k, _| { !is_hidden(k) || { - headers_removed.insert(k.clone()); + headers_removed.insert(k.to_string()); false } }); @@ -326,12 +321,9 @@ impl MimeMessage { let mut from = from.context("No from in message")?; let private_keyring = load_self_secret_keyring(context).await?; - let allow_aeap = get_encrypted_mime(&mail).is_some(); - let dkim_results = handle_authres(context, &mail, &from.addr).await?; let mut gossiped_keys = Default::default(); - let mut from_is_signed = false; hop_info += "\n\n"; hop_info += &dkim_results.to_string(); @@ -407,19 +399,37 @@ impl MimeMessage { None }; - // The peerstate that will be used to validate the signatures. - let mut peerstate = get_autocrypt_peerstate( - context, - &from.addr, - autocrypt_header.as_ref(), - timestamp_sent, - allow_aeap, - ) - .await?; + let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header { + let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex(); + let inserted = context + .sql + .execute( + "INSERT INTO public_keys (fingerprint, public_key) + VALUES (?, ?) + ON CONFLICT (fingerprint) + DO NOTHING", + (&fingerprint, autocrypt_header.public_key.to_bytes()), + ) + .await?; + if inserted > 0 { + info!( + context, + "Saved key with fingerprint {fingerprint} from the Autocrypt header" + ); + } + Some(fingerprint) + } else { + None + }; - let public_keyring = match peerstate.is_none() && !incoming { - true => key::load_self_public_keyring(context).await?, - false => keyring_from_peerstate(peerstate.as_ref()), + let public_keyring = if incoming { + if let Some(autocrypt_header) = autocrypt_header { + vec![autocrypt_header.public_key] + } else { + vec![] + } + } else { + key::load_self_public_keyring(context).await? }; let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg { @@ -482,14 +492,8 @@ impl MimeMessage { // but only if the mail was correctly signed. Probably it's ok to not require // encryption here, but let's follow the standard. let gossip_headers = mail.headers.get_all_values("Autocrypt-Gossip"); - gossiped_keys = update_gossip_peerstates( - context, - timestamp_sent, - &from.addr, - &recipients, - gossip_headers, - ) - .await?; + gossiped_keys = + parse_gossip_headers(context, &from.addr, &recipients, gossip_headers).await?; } if let Some(inner_from) = inner_from { @@ -514,30 +518,14 @@ impl MimeMessage { bail!("From header is forged"); } from = inner_from; - from_is_signed = !signatures.is_empty(); } } if signatures.is_empty() { Self::remove_secured_headers(&mut headers, &mut headers_removed); - - // If it is not a read receipt, degrade encryption. - if let (Some(peerstate), Ok(mail)) = (&mut peerstate, mail) { - if timestamp_sent > peerstate.last_seen_autocrypt - && mail.ctype.mimetype != "multipart/report" - { - peerstate.degrade_encryption(timestamp_sent); - } - } } if !is_encrypted { signatures.clear(); } - if let Some(peerstate) = &mut peerstate { - if peerstate.prefer_encrypt != EncryptPreference::Mutual && !signatures.is_empty() { - peerstate.prefer_encrypt = EncryptPreference::Mutual; - peerstate.save_to_db(&context.sql).await?; - } - } let mut parser = MimeMessage { parts: Vec::new(), @@ -549,15 +537,13 @@ impl MimeMessage { past_members, list_post, from, - from_is_signed, incoming, chat_disposition_notification_to, - autocrypt_header, - peerstate, decrypting_failed: mail.is_err(), // only non-empty if it was a valid autocrypt message signatures, + autocrypt_fingerprint, gossiped_keys, is_forwarded: false, mdn_reports: Vec::new(), @@ -620,10 +606,7 @@ impl MimeMessage { parser.maybe_remove_inline_mailinglist_footer(); parser.heuristically_parse_ndn(context).await; parser.parse_headers(context).await?; - - if parser.is_mime_modified { - parser.decoded_data = mail_raw; - } + parser.decoded_data = mail_raw; Ok(parser) } @@ -1338,14 +1321,13 @@ impl MimeMessage { if decoded_data.is_empty() { return Ok(()); } - if let Some(peerstate) = &mut self.peerstate { - if peerstate.prefer_encrypt != EncryptPreference::Mutual - && mime_type.type_() == mime::APPLICATION - && mime_type.subtype().as_str() == "pgp-keys" - && Self::try_set_peer_key_from_file_part(context, peerstate, decoded_data).await? - { - return Ok(()); - } + + // Process attached PGP keys. + if mime_type.type_() == mime::APPLICATION + && mime_type.subtype().as_str() == "pgp-keys" + && Self::try_set_peer_key_from_file_part(context, decoded_data).await? + { + return Ok(()); } let mut part = Part::default(); let msg_type = if context @@ -1438,10 +1420,9 @@ impl MimeMessage { Ok(()) } - /// Returns whether a key from the attachment was set as peer's pubkey. + /// Returns whether a key from the attachment was saved. async fn try_set_peer_key_from_file_part( context: &Context, - peerstate: &mut Peerstate, decoded_data: &[u8], ) -> Result { let key = match str::from_utf8(decoded_data) { @@ -1455,45 +1436,30 @@ impl MimeMessage { Err(err) => { warn!( context, - "PGP key attachment is not an ASCII-armored file: {:#}", err + "PGP key attachment is not an ASCII-armored file: {err:#}." ); return Ok(false); } Ok((key, _)) => key, }; if let Err(err) = key.verify() { - warn!(context, "attached PGP key verification failed: {}", err); + warn!(context, "Attached PGP key verification failed: {err:#}."); return Ok(false); } - if !key.details.users.iter().any(|user| { - user.id - .id() - .ends_with((String::from("<") + &peerstate.addr + ">").as_bytes()) - }) { - return Ok(false); - } - if let Some(curr_key) = &peerstate.public_key { - if key != *curr_key && peerstate.prefer_encrypt != EncryptPreference::Reset { - // We don't want to break the existing Autocrypt setup. Yes, it's unlikely that a - // user have an Autocrypt-capable MUA and also attaches a key, but if that's the - // case, let 'em first disable Autocrypt and then change the key by attaching it. - warn!( - context, - "not using attached PGP key for peer '{}' because another one is already set \ - with prefer-encrypt={}", - peerstate.addr, - peerstate.prefer_encrypt, - ); - return Ok(false); - } - } - peerstate.public_key = Some(key); - info!( - context, - "using attached PGP key for peer '{}' with prefer-encrypt=mutual", peerstate.addr, - ); - peerstate.prefer_encrypt = EncryptPreference::Mutual; - peerstate.save_to_db(&context.sql).await?; + + let fingerprint = key.dc_fingerprint().hex(); + context + .sql + .execute( + "INSERT INTO public_keys (fingerprint, public_key) + VALUES (?, ?) + ON CONFLICT (fingerprint) + DO NOTHING", + (&fingerprint, key.to_bytes()), + ) + .await?; + + info!(context, "Imported PGP key {fingerprint} from attachment."); Ok(true) } @@ -1887,6 +1853,19 @@ impl MimeMessage { .collect() }) } + + /// Returns list of fingerprints from + /// `Chat-Group-Member-Fpr` header. + pub fn chat_group_member_fingerprints(&self) -> Vec { + if let Some(header) = self.get_header(HeaderDef::ChatGroupMemberFpr) { + header + .split_ascii_whitespace() + .filter_map(|fpr| Fingerprint::from_str(fpr).ok()) + .collect() + } else { + Vec::new() + } + } } fn remove_header( @@ -1902,14 +1881,13 @@ fn remove_header( } } -/// Parses `Autocrypt-Gossip` headers from the email and applies them to peerstates. -/// Params: -/// from: The address which sent the message currently being parsed +/// Parses `Autocrypt-Gossip` headers from the email, +/// saves the keys into the `public_keys` table, +/// and returns them in a HashMap. /// -/// Returns the set of mail recipient addresses for which valid gossip headers were found. -async fn update_gossip_peerstates( +/// * `from`: The address which sent the message currently being parsed +async fn parse_gossip_headers( context: &Context, - message_time: i64, from: &str, recipients: &[SingleInfo], gossip_headers: Vec, @@ -1937,7 +1915,7 @@ async fn update_gossip_peerstates( continue; } if addr_cmp(from, &header.addr) { - // Non-standard, but anyway we can't update the cached peerstate here. + // Non-standard, might not be necessary to have this check here warn!( context, "Ignoring gossiped \"{}\" as it equals the From address", &header.addr, @@ -1945,18 +1923,16 @@ async fn update_gossip_peerstates( continue; } - let peerstate; - if let Some(mut p) = Peerstate::from_addr(context, &header.addr).await? { - p.apply_gossip(&header, message_time); - p.save_to_db(&context.sql).await?; - peerstate = p; - } else { - let p = Peerstate::from_gossip(&header, message_time); - p.save_to_db(&context.sql).await?; - peerstate = p; - }; - peerstate - .handle_fingerprint_change(context, message_time) + let fingerprint = header.public_key.dc_fingerprint().hex(); + context + .sql + .execute( + "INSERT INTO public_keys (fingerprint, public_key) + VALUES (?, ?) + ON CONFLICT (fingerprint) + DO NOTHING", + (&fingerprint, header.public_key.to_bytes()), + ) .await?; gossiped_keys.insert(header.addr.to_lowercase(), header.public_key); diff --git a/src/param.rs b/src/param.rs index 9500778a2..8fb5448fc 100644 --- a/src/param.rs +++ b/src/param.rs @@ -98,6 +98,11 @@ pub enum Param { Cmd = b'S', /// For Messages + /// + /// For "MemberRemovedFromGroup" this is the email address + /// removed from the group. + /// + /// For "MemberAddedToGroup" this is the email address added to the group. Arg = b'E', /// For Messages diff --git a/src/peerstate.rs b/src/peerstate.rs deleted file mode 100644 index 997bba6db..000000000 --- a/src/peerstate.rs +++ /dev/null @@ -1,1064 +0,0 @@ -//! # [Autocrypt Peer State](https://autocrypt.org/level1.html#peer-state-management) module. - -use std::mem; - -use anyhow::{Context as _, Error, Result}; -use deltachat_contact_tools::{addr_cmp, ContactAddress}; -use num_traits::FromPrimitive; - -use crate::aheader::{Aheader, EncryptPreference}; -use crate::chat::{self, Chat}; -use crate::chatlist::Chatlist; -use crate::config::Config; -use crate::constants::Chattype; -use crate::contact::{Contact, Origin}; -use crate::context::Context; -use crate::events::EventType; -use crate::key::{DcKey, Fingerprint, SignedPublicKey}; -use crate::log::{info, warn}; -use crate::message::Message; -use crate::mimeparser::SystemMessage; -use crate::sql::Sql; -use crate::{chatlist_events, stock_str}; - -/// Type of the public key stored inside the peerstate. -#[derive(Debug)] -pub enum PeerstateKeyType { - /// Public key sent in the `Autocrypt-Gossip` header. - GossipKey, - - /// Public key sent in the `Autocrypt` header. - PublicKey, -} - -/// Peerstate represents the state of an Autocrypt peer. -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct Peerstate { - /// E-mail address of the contact. - pub addr: String, - - /// Timestamp of the latest peerstate update. - /// - /// Updated when a message is received from a contact, - /// either with or without `Autocrypt` header. - pub last_seen: i64, - - /// Timestamp of the latest `Autocrypt` header reception. - pub last_seen_autocrypt: i64, - - /// Encryption preference of the contact. - pub prefer_encrypt: EncryptPreference, - - /// Public key of the contact received in `Autocrypt` header. - pub public_key: Option, - - /// Fingerprint of the contact public key. - pub public_key_fingerprint: Option, - - /// Public key of the contact received in `Autocrypt-Gossip` header. - pub gossip_key: Option, - - /// Timestamp of the latest `Autocrypt-Gossip` header reception. - /// - /// It is stored to avoid applying outdated gossiped key - /// from delayed or reordered messages. - pub gossip_timestamp: i64, - - /// Fingerprint of the contact gossip key. - pub gossip_key_fingerprint: Option, - - /// Public key of the contact at the time it was verified, - /// either directly or via gossip from the verified contact. - pub verified_key: Option, - - /// Fingerprint of the verified public key. - pub verified_key_fingerprint: Option, - - /// The address that introduced this verified key. - pub verifier: Option, - - /// Secondary public verified key of the contact. - /// It could be a contact gossiped by another verified contact in a shared group - /// or a key that was previously used as a verified key. - pub secondary_verified_key: Option, - - /// Fingerprint of the secondary verified public key. - pub secondary_verified_key_fingerprint: Option, - - /// The address that introduced secondary verified key. - pub secondary_verifier: Option, - - /// Row ID of the key in the `keypairs` table - /// that we think the peer knows as verified. - pub backward_verified_key_id: Option, - - /// True if it was detected - /// that the fingerprint of the key used in chats with - /// opportunistic encryption was changed after Peerstate creation. - pub fingerprint_changed: bool, -} - -impl Peerstate { - /// Creates a peerstate from the `Autocrypt` header. - pub fn from_header(header: &Aheader, message_time: i64) -> Self { - Self::from_public_key( - &header.addr, - message_time, - header.prefer_encrypt, - &header.public_key, - ) - } - - /// Creates a peerstate from the given public key. - pub fn from_public_key( - addr: &str, - last_seen: i64, - prefer_encrypt: EncryptPreference, - public_key: &SignedPublicKey, - ) -> Self { - Peerstate { - addr: addr.to_string(), - last_seen, - last_seen_autocrypt: last_seen, - prefer_encrypt, - public_key: Some(public_key.clone()), - public_key_fingerprint: Some(public_key.dc_fingerprint()), - gossip_key: None, - gossip_key_fingerprint: None, - gossip_timestamp: 0, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - } - } - - /// Create a peerstate from the `Autocrypt-Gossip` header. - pub fn from_gossip(gossip_header: &Aheader, message_time: i64) -> Self { - Peerstate { - addr: gossip_header.addr.clone(), - last_seen: 0, - last_seen_autocrypt: 0, - - // Non-standard extension. According to Autocrypt 1.1.0 gossip headers SHOULD NOT - // contain encryption preference. - // - // Delta Chat includes encryption preference to ensure new users introduced to a group - // learn encryption preferences of other members immediately and don't send unencrypted - // messages to a group where everyone prefers encryption. - prefer_encrypt: gossip_header.prefer_encrypt, - public_key: None, - public_key_fingerprint: None, - gossip_key: Some(gossip_header.public_key.clone()), - gossip_key_fingerprint: Some(gossip_header.public_key.dc_fingerprint()), - gossip_timestamp: message_time, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - } - } - - /// Loads peerstate corresponding to the given address from the database. - pub async fn from_addr(context: &Context, addr: &str) -> Result> { - if context.is_self_addr(addr).await? { - return Ok(None); - } - let query = "SELECT addr, last_seen, last_seen_autocrypt, prefer_encrypted, public_key, \ - gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ - verified_key, verified_key_fingerprint, \ - verifier, \ - secondary_verified_key, secondary_verified_key_fingerprint, \ - secondary_verifier, \ - backward_verified_key_id \ - FROM acpeerstates \ - WHERE addr=? COLLATE NOCASE LIMIT 1;"; - Self::from_stmt(context, query, (addr,)).await - } - - /// Loads peerstate corresponding to the given fingerprint from the database. - pub async fn from_fingerprint( - context: &Context, - fingerprint: &Fingerprint, - ) -> Result> { - // NOTE: If it's our key fingerprint, this returns None currently. - let query = "SELECT addr, last_seen, last_seen_autocrypt, prefer_encrypted, public_key, \ - gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ - verified_key, verified_key_fingerprint, \ - verifier, \ - secondary_verified_key, secondary_verified_key_fingerprint, \ - secondary_verifier, \ - backward_verified_key_id \ - FROM acpeerstates \ - WHERE public_key_fingerprint=? \ - OR gossip_key_fingerprint=? \ - ORDER BY public_key_fingerprint=? DESC LIMIT 1;"; - let fp = fingerprint.hex(); - Self::from_stmt(context, query, (&fp, &fp, &fp)).await - } - - /// Loads peerstate by address or verified fingerprint. - /// - /// If the address is different but verified fingerprint is the same, - /// peerstate with corresponding verified fingerprint is preferred. - pub async fn from_verified_fingerprint_or_addr( - context: &Context, - fingerprint: &Fingerprint, - addr: &str, - ) -> Result> { - if context.is_self_addr(addr).await? { - return Ok(None); - } - let query = "SELECT addr, last_seen, last_seen_autocrypt, prefer_encrypted, public_key, \ - gossip_timestamp, gossip_key, public_key_fingerprint, gossip_key_fingerprint, \ - verified_key, verified_key_fingerprint, \ - verifier, \ - secondary_verified_key, secondary_verified_key_fingerprint, \ - secondary_verifier, \ - backward_verified_key_id \ - FROM acpeerstates \ - WHERE verified_key_fingerprint=? \ - OR addr=? COLLATE NOCASE \ - ORDER BY verified_key_fingerprint=? DESC, addr=? COLLATE NOCASE DESC, \ - last_seen DESC LIMIT 1;"; - let fp = fingerprint.hex(); - Self::from_stmt(context, query, (&fp, addr, &fp, addr)).await - } - - async fn from_stmt( - context: &Context, - query: &str, - params: impl rusqlite::Params + Send, - ) -> Result> { - let peerstate = context - .sql - .query_row_optional(query, params, |row| { - let res = Peerstate { - addr: row.get("addr")?, - last_seen: row.get("last_seen")?, - last_seen_autocrypt: row.get("last_seen_autocrypt")?, - prefer_encrypt: EncryptPreference::from_i32(row.get("prefer_encrypted")?) - .unwrap_or_default(), - public_key: row - .get("public_key") - .ok() - .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()), - public_key_fingerprint: row - .get::<_, Option>("public_key_fingerprint")? - .map(|s| s.parse::()) - .transpose() - .unwrap_or_default(), - gossip_key: row - .get("gossip_key") - .ok() - .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()), - gossip_key_fingerprint: row - .get::<_, Option>("gossip_key_fingerprint")? - .map(|s| s.parse::()) - .transpose() - .unwrap_or_default(), - gossip_timestamp: row.get("gossip_timestamp")?, - verified_key: row - .get("verified_key") - .ok() - .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()), - verified_key_fingerprint: row - .get::<_, Option>("verified_key_fingerprint")? - .map(|s| s.parse::()) - .transpose() - .unwrap_or_default(), - verifier: { - let verifier: Option = row.get("verifier")?; - verifier.filter(|s| !s.is_empty()) - }, - secondary_verified_key: row - .get("secondary_verified_key") - .ok() - .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()), - secondary_verified_key_fingerprint: row - .get::<_, Option>("secondary_verified_key_fingerprint")? - .map(|s| s.parse::()) - .transpose() - .unwrap_or_default(), - secondary_verifier: { - let secondary_verifier: Option = row.get("secondary_verifier")?; - secondary_verifier.filter(|s| !s.is_empty()) - }, - backward_verified_key_id: row.get("backward_verified_key_id")?, - fingerprint_changed: false, - }; - - Ok(res) - }) - .await?; - Ok(peerstate) - } - - /// Re-calculate `self.public_key_fingerprint` and `self.gossip_key_fingerprint`. - /// If one of them was changed, `self.fingerprint_changed` is set to `true`. - /// - /// Call this after you changed `self.public_key` or `self.gossip_key`. - pub fn recalc_fingerprint(&mut self) { - if let Some(ref public_key) = self.public_key { - let old_public_fingerprint = self.public_key_fingerprint.take(); - self.public_key_fingerprint = Some(public_key.dc_fingerprint()); - - if old_public_fingerprint.is_some() - && old_public_fingerprint != self.public_key_fingerprint - { - self.fingerprint_changed = true; - } - } - - if let Some(ref gossip_key) = self.gossip_key { - let old_gossip_fingerprint = self.gossip_key_fingerprint.take(); - self.gossip_key_fingerprint = Some(gossip_key.dc_fingerprint()); - - if old_gossip_fingerprint.is_none() - || self.gossip_key_fingerprint.is_none() - || old_gossip_fingerprint != self.gossip_key_fingerprint - { - // Warn about gossip key change only if there is no public key obtained from - // Autocrypt header, which overrides gossip key. - if old_gossip_fingerprint.is_some() && self.public_key_fingerprint.is_none() { - self.fingerprint_changed = true; - } - } - } - } - - /// Reset Autocrypt peerstate. - /// - /// Used when it is detected that the contact no longer uses Autocrypt. - pub fn degrade_encryption(&mut self, message_time: i64) { - self.prefer_encrypt = EncryptPreference::Reset; - self.last_seen = message_time; - } - - /// Updates peerstate according to the given `Autocrypt` header. - pub fn apply_header(&mut self, context: &Context, header: &Aheader, message_time: i64) { - if !addr_cmp(&self.addr, &header.addr) { - return; - } - - if message_time >= self.last_seen { - self.last_seen = message_time; - self.last_seen_autocrypt = message_time; - if (header.prefer_encrypt == EncryptPreference::Mutual - || header.prefer_encrypt == EncryptPreference::NoPreference) - && header.prefer_encrypt != self.prefer_encrypt - { - self.prefer_encrypt = header.prefer_encrypt; - } - - if self.public_key.as_ref() != Some(&header.public_key) { - self.public_key = Some(header.public_key.clone()); - self.recalc_fingerprint(); - } - } else { - warn!( - context, - "Ignoring outdated Autocrypt header because message_time={} < last_seen={}.", - message_time, - self.last_seen - ); - } - } - - /// Updates peerstate according to the given `Autocrypt-Gossip` header. - pub fn apply_gossip(&mut self, gossip_header: &Aheader, message_time: i64) { - if self.addr.to_lowercase() != gossip_header.addr.to_lowercase() { - return; - } - - if message_time >= self.gossip_timestamp { - self.gossip_timestamp = message_time; - if self.gossip_key.as_ref() != Some(&gossip_header.public_key) { - self.gossip_key = Some(gossip_header.public_key.clone()); - self.recalc_fingerprint(); - } - - // This is non-standard. - // - // According to Autocrypt 1.1.0 gossip headers SHOULD NOT - // contain encryption preference, but we include it into - // Autocrypt-Gossip and apply it one way (from - // "nopreference" to "mutual"). - // - // This is compatible to standard clients, because they - // can't distinguish it from the case where we have - // contacted the client in the past and received this - // preference via Autocrypt header. - if self.last_seen_autocrypt == 0 - && self.prefer_encrypt == EncryptPreference::NoPreference - && gossip_header.prefer_encrypt == EncryptPreference::Mutual - { - self.prefer_encrypt = EncryptPreference::Mutual; - } - }; - } - - /// Converts the peerstate into the contact public key. - /// - /// Similar to [`Self::peek_key`], but consumes the peerstate and returns owned key. - pub fn take_key(mut self, verified: bool) -> Option { - if verified { - self.verified_key.take() - } else { - self.public_key.take().or_else(|| self.gossip_key.take()) - } - } - - /// Returns a reference to the contact public key. - /// - /// `verified` determines the required verification status of the key. - /// If verified key is requested, returns the verified key, - /// otherwise returns the Autocrypt key. - /// - /// Returned key is suitable for sending in `Autocrypt-Gossip` header. - /// - /// Returns `None` if there is no suitable public key. - pub fn peek_key(&self, verified: bool) -> Option<&SignedPublicKey> { - if verified { - self.verified_key.as_ref() - } else { - self.public_key.as_ref().or(self.gossip_key.as_ref()) - } - } - - /// 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, verified: bool) -> Option<&Fingerprint> { - if verified { - self.verified_key_fingerprint.as_ref() - } else { - 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(true); - - verified.is_some() && verified == self.peek_key_fingerprint(false) - } - - pub(crate) async fn is_backward_verified(&self, context: &Context) -> Result { - let Some(backward_verified_key_id) = self.backward_verified_key_id else { - return Ok(false); - }; - - let self_key_id = context.get_config_i64(Config::KeyId).await?; - - let backward_verified = backward_verified_key_id == self_key_id; - Ok(backward_verified) - } - - /// Set this peerstate to verified; - /// make sure to call `self.save_to_db` to save these changes. - /// - /// Params: - /// - /// * key: The new verified key. - /// * fingerprint: Only set to verified if the key's fingerprint matches this. - /// * verifier: - /// The address which introduces the given contact. - /// If we are verifying the contact, use that contacts address. - pub fn set_verified( - &mut self, - key: SignedPublicKey, - fingerprint: Fingerprint, - verifier: String, - ) -> Result<()> { - if key.dc_fingerprint() == fingerprint { - self.verified_key = Some(key); - self.verified_key_fingerprint = Some(fingerprint); - self.verifier = Some(verifier); - Ok(()) - } else { - Err(Error::msg(format!( - "{fingerprint} is not peer's key fingerprint", - ))) - } - } - - /// Sets the gossiped key as the secondary verified key. - /// - /// If gossiped key is the same as the current verified key, - /// do nothing to avoid overwriting secondary verified key - /// which may be different. - pub fn set_secondary_verified_key(&mut self, gossip_key: SignedPublicKey, verifier: String) { - let fingerprint = gossip_key.dc_fingerprint(); - if self.verified_key_fingerprint.as_ref() != Some(&fingerprint) { - self.secondary_verified_key = Some(gossip_key); - self.secondary_verified_key_fingerprint = Some(fingerprint); - self.secondary_verifier = Some(verifier); - } - } - - /// Saves the peerstate to the database. - pub async fn save_to_db(&self, sql: &Sql) -> Result<()> { - self.save_to_db_ex(sql, None).await - } - - /// Saves the peerstate to the database. - /// - /// * `old_addr`: Old address of the peerstate in case of an AEAP transition. - pub(crate) async fn save_to_db_ex(&self, sql: &Sql, old_addr: Option<&str>) -> Result<()> { - let trans_fn = |t: &mut rusqlite::Transaction| { - let verified_key_fingerprint = - self.verified_key_fingerprint.as_ref().map(|fp| fp.hex()); - if let Some(old_addr) = old_addr { - // We are doing an AEAP transition to the new address and the SQL INSERT below will - // save the existing peerstate as belonging to this new address. We now need to - // "unverify" the peerstate that belongs to the current address in case if the - // contact later wants to move back to the current address. Otherwise the old entry - // will be just found and updated instead of doing AEAP. We can't just delete the - // existing peerstate as this would break encryption to it. This is critical for - // non-verified groups -- if we can't encrypt to the old address, we can't securely - // remove it from the group (to add the new one instead). - // - // NB: We check that `verified_key_fingerprint` hasn't changed to protect from - // possible races. - t.execute( - "UPDATE acpeerstates - SET verified_key=NULL, verified_key_fingerprint='', verifier='' - WHERE addr=? AND verified_key_fingerprint=?", - (old_addr, &verified_key_fingerprint), - )?; - } - t.execute( - "INSERT INTO acpeerstates ( - last_seen, - last_seen_autocrypt, - prefer_encrypted, - public_key, - gossip_timestamp, - gossip_key, - public_key_fingerprint, - gossip_key_fingerprint, - verified_key, - verified_key_fingerprint, - verifier, - secondary_verified_key, - secondary_verified_key_fingerprint, - secondary_verifier, - backward_verified_key_id, - addr) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT (addr) - DO UPDATE SET - last_seen = excluded.last_seen, - last_seen_autocrypt = excluded.last_seen_autocrypt, - prefer_encrypted = excluded.prefer_encrypted, - public_key = excluded.public_key, - gossip_timestamp = excluded.gossip_timestamp, - gossip_key = excluded.gossip_key, - public_key_fingerprint = excluded.public_key_fingerprint, - gossip_key_fingerprint = excluded.gossip_key_fingerprint, - verified_key = excluded.verified_key, - verified_key_fingerprint = excluded.verified_key_fingerprint, - verifier = excluded.verifier, - secondary_verified_key = excluded.secondary_verified_key, - secondary_verified_key_fingerprint = excluded.secondary_verified_key_fingerprint, - secondary_verifier = excluded.secondary_verifier, - backward_verified_key_id = excluded.backward_verified_key_id", - ( - self.last_seen, - self.last_seen_autocrypt, - self.prefer_encrypt as i64, - self.public_key.as_ref().map(|k| k.to_bytes()), - self.gossip_timestamp, - self.gossip_key.as_ref().map(|k| k.to_bytes()), - self.public_key_fingerprint.as_ref().map(|fp| fp.hex()), - self.gossip_key_fingerprint.as_ref().map(|fp| fp.hex()), - self.verified_key.as_ref().map(|k| k.to_bytes()), - &verified_key_fingerprint, - self.verifier.as_deref().unwrap_or(""), - self.secondary_verified_key.as_ref().map(|k| k.to_bytes()), - self.secondary_verified_key_fingerprint - .as_ref() - .map(|fp| fp.hex()), - self.secondary_verifier.as_deref().unwrap_or(""), - self.backward_verified_key_id, - &self.addr, - ), - )?; - Ok(()) - }; - sql.transaction(trans_fn).await - } - - /// Returns the address that verified the contact - pub fn get_verifier(&self) -> Option<&str> { - self.verifier.as_deref() - } - - /// Add an info message to all the chats with this contact, informing about - /// a [`PeerstateChange`]. - /// - /// Also, in the case of an address change (AEAP), replace the old address - /// with the new address in all chats. - async fn handle_setup_change( - &self, - context: &Context, - timestamp: i64, - change: PeerstateChange, - ) -> Result<()> { - if context.is_self_addr(&self.addr).await? { - // Do not try to search all the chats with self. - return Ok(()); - } - - let contact_id = context - .sql - .query_get_value( - "SELECT id FROM contacts WHERE addr=? COLLATE NOCASE;", - (&self.addr,), - ) - .await? - .with_context(|| format!("contact with peerstate.addr {:?} not found", &self.addr))?; - - let chats = Chatlist::try_load(context, 0, None, Some(contact_id)).await?; - let msg = match &change { - PeerstateChange::FingerprintChange => { - stock_str::contact_setup_changed(context, &self.addr).await - } - PeerstateChange::Aeap(new_addr) => { - let old_contact = Contact::get_by_id(context, contact_id).await?; - stock_str::aeap_addr_changed( - context, - old_contact.get_display_name(), - &self.addr, - new_addr, - ) - .await - } - }; - for (chat_id, msg_id) in chats.iter() { - let timestamp_sort = if let Some(msg_id) = msg_id { - let lastmsg = Message::load_from_db(context, *msg_id).await?; - lastmsg.timestamp_sort - } else { - chat_id.created_timestamp(context).await? - }; - - if let PeerstateChange::Aeap(new_addr) = &change { - let chat = Chat::load_from_db(context, *chat_id).await?; - - if chat.typ == Chattype::Group && !chat.is_protected() { - // Don't add an info_msg to the group, in order not to make the user think - // that the address was automatically replaced in the group. - continue; - } - - // For security reasons, for now, we only do the AEAP transition if the fingerprint - // is verified (that's what from_verified_fingerprint_or_addr() does). - // In order to not have inconsistent group membership state, we then only do the - // transition in verified groups and in broadcast lists. - if (chat.typ == Chattype::Group && chat.is_protected()) - || chat.typ == Chattype::Broadcast - { - match ContactAddress::new(new_addr) { - Ok(new_addr) => { - let (new_contact_id, _) = Contact::add_or_lookup( - context, - "", - &new_addr, - Origin::IncomingUnknownFrom, - ) - .await?; - context - .sql - .transaction(|transaction| { - transaction.execute( - "UPDATE chats_contacts - SET remove_timestamp=MAX(add_timestamp+1, ?) - WHERE chat_id=? AND contact_id=?", - (timestamp, chat_id, contact_id), - )?; - transaction.execute( - "INSERT INTO chats_contacts - (chat_id, contact_id, add_timestamp) - VALUES (?1, ?2, ?3) - ON CONFLICT (chat_id, contact_id) - DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)", - (chat_id, new_contact_id, timestamp), - )?; - Ok(()) - }) - .await?; - - context.emit_event(EventType::ChatModified(*chat_id)); - } - Err(err) => { - warn!( - context, - "New address {:?} is not valid, not doing AEAP: {:#}.", - new_addr, - err - ) - } - } - } - } - - chat::add_info_msg_with_cmd( - context, - *chat_id, - &msg, - SystemMessage::Unknown, - timestamp_sort, - Some(timestamp), - None, - None, - None, - ) - .await?; - } - - chatlist_events::emit_chatlist_changed(context); - // update the chats the contact is part of - chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id); - Ok(()) - } - - /// Adds a warning to all the chats corresponding to peerstate if fingerprint has changed. - pub(crate) async fn handle_fingerprint_change( - &self, - context: &Context, - timestamp: i64, - ) -> Result<()> { - if self.fingerprint_changed { - self.handle_setup_change(context, timestamp, PeerstateChange::FingerprintChange) - .await?; - } - Ok(()) - } -} - -/// Do an AEAP transition, if necessary. -/// AEAP stands for "Automatic Email Address Porting." -/// -/// In `drafts/aeap_mvp.md` there is a "big picture" overview over AEAP. -pub(crate) async fn maybe_do_aeap_transition( - context: &Context, - mime_parser: &mut crate::mimeparser::MimeMessage, -) -> Result<()> { - let Some(peerstate) = &mime_parser.peerstate else { - return Ok(()); - }; - - // If the from addr is different from the peerstate address we know, - // we may want to do an AEAP transition. - if !addr_cmp(&peerstate.addr, &mime_parser.from.addr) { - // Check if it's a chat message; we do this to avoid - // some accidental transitions if someone writes from multiple - // addresses with an MUA. - if !mime_parser.has_chat_version() { - info!( - context, - "Not doing AEAP from {} to {} because the message is not a chat message.", - &peerstate.addr, - &mime_parser.from.addr - ); - return Ok(()); - } - - // Check if the message is encrypted and signed correctly. If it's not encrypted, it's - // probably from a new contact sharing the same key. - if mime_parser.signatures.is_empty() { - info!( - context, - "Not doing AEAP from {} to {} because the message is not encrypted and signed.", - &peerstate.addr, - &mime_parser.from.addr - ); - return Ok(()); - } - - // Check if the From: address was also in the signed part of the email. - // Without this check, an attacker could replay a message from Alice - // to Bob. Then Bob's device would do an AEAP transition from Alice's - // to the attacker's address, allowing for easier phishing. - if !mime_parser.from_is_signed { - info!( - context, - "Not doing AEAP from {} to {} because From: is not signed.", - &peerstate.addr, - &mime_parser.from.addr - ); - return Ok(()); - } - - // DC avoids sending messages with the same timestamp, that's why messages - // with equal timestamps are ignored here unlike in `Peerstate::apply_header()`. - if mime_parser.timestamp_sent <= peerstate.last_seen { - info!( - context, - "Not doing AEAP from {} to {} because {} < {}.", - &peerstate.addr, - &mime_parser.from.addr, - mime_parser.timestamp_sent, - peerstate.last_seen - ); - return Ok(()); - } - - info!( - context, - "Doing AEAP transition from {} to {}.", &peerstate.addr, &mime_parser.from.addr - ); - - let peerstate = mime_parser.peerstate.as_mut().context("no peerstate??")?; - // Add info messages to chats with this (verified) contact - // - peerstate - .handle_setup_change( - context, - mime_parser.timestamp_sent, - PeerstateChange::Aeap(mime_parser.from.addr.clone()), - ) - .await?; - - let old_addr = mem::take(&mut peerstate.addr); - peerstate.addr.clone_from(&mime_parser.from.addr); - let header = mime_parser.autocrypt_header.as_ref().context( - "Internal error: Tried to do an AEAP transition without an autocrypt header??", - )?; - peerstate.apply_header(context, header, mime_parser.timestamp_sent); - - peerstate - .save_to_db_ex(&context.sql, Some(&old_addr)) - .await?; - } - - Ok(()) -} - -/// Type of the peerstate change. -/// -/// Changes to the peerstate are notified to the user via a message -/// explaining the happened change. -enum PeerstateChange { - /// The contact's public key fingerprint changed, likely because - /// the contact uses a new device and didn't transfer their key. - FingerprintChange, - /// The contact changed their address to the given new address - /// (Automatic Email Address Porting). - Aeap(String), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_utils::alice_keypair; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_peerstate_save_to_db() { - let ctx = crate::test_utils::TestContext::new().await; - let addr = "hello@mail.com"; - - let pub_key = alice_keypair().public; - - let peerstate = Peerstate { - addr: addr.into(), - last_seen: 10, - last_seen_autocrypt: 11, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: Some(pub_key.clone()), - gossip_timestamp: 12, - gossip_key_fingerprint: Some(pub_key.dc_fingerprint()), - verified_key: Some(pub_key.clone()), - verified_key_fingerprint: Some(pub_key.dc_fingerprint()), - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "failed to save to db" - ); - - let peerstate_new = Peerstate::from_addr(&ctx.ctx, addr) - .await - .expect("failed to load peerstate from db") - .expect("no peerstate found in the database"); - - assert_eq!(peerstate, peerstate_new); - let peerstate_new2 = Peerstate::from_fingerprint(&ctx.ctx, &pub_key.dc_fingerprint()) - .await - .expect("failed to load peerstate from db") - .expect("no peerstate found in the database"); - assert_eq!(peerstate, peerstate_new2); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_peerstate_double_create() { - let ctx = crate::test_utils::TestContext::new().await; - let addr = "hello@mail.com"; - let pub_key = alice_keypair().public; - - let peerstate = Peerstate { - addr: addr.into(), - last_seen: 10, - last_seen_autocrypt: 11, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: None, - gossip_timestamp: 12, - gossip_key_fingerprint: None, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "failed to save" - ); - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "double-call with create failed" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_peerstate_with_empty_gossip_key_save_to_db() { - let ctx = crate::test_utils::TestContext::new().await; - let addr = "hello@mail.com"; - - let pub_key = alice_keypair().public; - - let peerstate = Peerstate { - addr: addr.into(), - last_seen: 10, - last_seen_autocrypt: 11, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: None, - gossip_timestamp: 12, - gossip_key_fingerprint: None, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "failed to save" - ); - - let peerstate_new = Peerstate::from_addr(&ctx.ctx, addr) - .await - .expect("failed to load peerstate from db"); - - assert_eq!(Some(peerstate), peerstate_new); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_peerstate_load_db_defaults() { - let ctx = crate::test_utils::TestContext::new().await; - let addr = "hello@mail.com"; - - // Old code created peerstates with this code and updated - // other values later. If UPDATE failed, other columns had - // default values, in particular fingerprints were set to - // empty strings instead of NULL. This should not be the case - // anymore, but the regression test still checks that defaults - // can be loaded without errors. - ctx.ctx - .sql - .execute("INSERT INTO acpeerstates (addr) VALUES(?)", (addr,)) - .await - .expect("Failed to write to the database"); - - let peerstate = Peerstate::from_addr(&ctx.ctx, addr) - .await - .expect("Failed to load peerstate from db") - .expect("Loaded peerstate is empty"); - - // Check that default values for fingerprints are treated like - // NULL. - assert_eq!(peerstate.public_key_fingerprint, None); - assert_eq!(peerstate.gossip_key_fingerprint, None); - assert_eq!(peerstate.verified_key_fingerprint, None); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_peerstate_degrade_reordering() { - let ctx = crate::test_utils::TestContext::new().await; - - let addr = "example@example.org"; - let pub_key = alice_keypair().public; - let header = Aheader::new(addr.to_string(), pub_key, EncryptPreference::Mutual); - - let mut peerstate = Peerstate { - addr: addr.to_string(), - last_seen: 0, - last_seen_autocrypt: 0, - prefer_encrypt: EncryptPreference::NoPreference, - public_key: None, - public_key_fingerprint: None, - gossip_key: None, - gossip_timestamp: 0, - gossip_key_fingerprint: None, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - - peerstate.apply_header(&ctx, &header, 100); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); - - peerstate.degrade_encryption(300); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Reset); - - // This has message time 200, while encryption was degraded at timestamp 300. - // Because of reordering, header should not be applied. - peerstate.apply_header(&ctx, &header, 200); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Reset); - - // Same header will be applied in the future. - peerstate.apply_header(&ctx, &header, 300); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); - } -} diff --git a/src/qr.rs b/src/qr.rs index edfd62c12..0afe6c667 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -11,9 +11,7 @@ use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC}; use serde::Deserialize; pub(crate) use self::dclogin_scheme::configure_from_login_qr; -use crate::chat::ChatIdBlocked; use crate::config::Config; -use crate::constants::Blocked; use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::events::EventType; @@ -21,7 +19,6 @@ use crate::key::Fingerprint; use crate::message::Message; use crate::net::http::post_empty; use crate::net::proxy::{ProxyConfig, DEFAULT_SOCKS_PORT}; -use crate::peerstate::Peerstate; use crate::token; use crate::tools::validate_id; @@ -44,7 +41,7 @@ pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP"; /// Version written to Backups and Backup-QR-Codes. /// Imports will fail when they have a larger version. -pub(crate) const DCBACKUP_VERSION: i32 = 2; +pub(crate) const DCBACKUP_VERSION: i32 = 3; /// Scanned QR code. #[derive(Debug, Clone, PartialEq, Eq)] @@ -457,17 +454,17 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { None }; - // retrieve known state for this fingerprint - let peerstate = Peerstate::from_fingerprint(context, &fingerprint) - .await - .context("Can't load peerstate")?; - if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) { let addr = ContactAddress::new(addr)?; - let (contact_id, _) = - Contact::add_or_lookup(context, &name, &addr, Origin::UnhandledSecurejoinQrScan) - .await - .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?; + let (contact_id, _) = Contact::add_or_lookup_ex( + context, + &name, + &addr, + &fingerprint.hex(), + Origin::UnhandledSecurejoinQrScan, + ) + .await + .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?; if let (Some(grpid), Some(grpname)) = (grpid, grpname) { if context @@ -529,21 +526,18 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { }) } } else if let Some(addr) = addr { - if let Some(peerstate) = peerstate { - let peerstate_addr = ContactAddress::new(&peerstate.addr)?; - let (contact_id, _) = - Contact::add_or_lookup(context, &name, &peerstate_addr, Origin::UnhandledQrScan) - .await - .context("add_or_lookup")?; - ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Request) - .await - .context("Failed to create (new) chat for contact")?; + let fingerprint = fingerprint.hex(); + let (contact_id, _) = + Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan) + .await?; + let contact = Contact::get_by_id(context, contact_id).await?; + + if contact.public_key(context).await?.is_some() { Ok(Qr::FprOk { contact_id }) } else { - let contact_id = Contact::lookup_id_by_addr(context, &addr, Origin::Unknown) - .await - .with_context(|| format!("Error looking up contact {addr:?}"))?; - Ok(Qr::FprMismatch { contact_id }) + Ok(Qr::FprMismatch { + contact_id: Some(contact_id), + }) } } else { Ok(Qr::FprWithoutAddr { diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs index fa507b1bd..ca96f6b3d 100644 --- a/src/qr/qr_tests.rs +++ b/src/qr/qr_tests.rs @@ -1,10 +1,8 @@ use super::*; -use crate::aheader::EncryptPreference; use crate::chat::{create_group_chat, ProtectionStatus}; use crate::config::Config; -use crate::key::DcKey; use crate::securejoin::get_securejoin_qr; -use crate::test_utils::{alice_keypair, TestContext}; +use crate::test_utils::{TestContext, TestContextManager}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_http() -> Result<()> { @@ -212,7 +210,7 @@ async fn test_decode_smtp() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_ideltachat_link() -> Result<()> { - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, @@ -233,7 +231,7 @@ async fn test_decode_ideltachat_link() -> Result<()> { // see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> { - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, @@ -246,7 +244,7 @@ async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_openpgp_group() -> Result<()> { - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" @@ -264,7 +262,7 @@ async fn test_decode_openpgp_group() -> Result<()> { } // Test it again with lowercased "openpgp4fpr:" uri scheme - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" @@ -289,7 +287,7 @@ async fn test_decode_openpgp_group() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_openpgp_invalid_token() -> Result<()> { - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; // Token cannot contain "/" let qr = check_qr( @@ -304,7 +302,7 @@ async fn test_decode_openpgp_invalid_token() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_openpgp_secure_join() -> Result<()> { - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, @@ -333,7 +331,7 @@ async fn test_decode_openpgp_secure_join() -> Result<()> { } // Regression test - let ctx = TestContext::new().await; + let ctx = TestContext::new_alice().await; let qr = check_qr( &ctx.ctx, "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" @@ -353,52 +351,29 @@ async fn test_decode_openpgp_secure_join() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_openpgp_fingerprint() -> Result<()> { - let ctx = TestContext::new().await; + let mut tcm = TestContextManager::new(); + let bob = &tcm.bob().await; + let alice = &tcm.alice().await; - let alice_contact_id = Contact::create(&ctx, "Alice", "alice@example.org") - .await - .context("failed to create contact")?; - let pub_key = alice_keypair().public; - let peerstate = Peerstate { - addr: "alice@example.org".to_string(), - last_seen: 1, - last_seen_autocrypt: 1, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: None, - gossip_timestamp: 0, - gossip_key_fingerprint: None, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "failed to save peerstate" - ); + let alice_contact = bob.add_or_lookup_contact(alice).await; + let alice_contact_id = alice_contact.id; let qr = check_qr( - &ctx.ctx, + bob, "OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org", ) .await?; if let Qr::FprMismatch { contact_id, .. } = qr { - assert_eq!(contact_id, Some(alice_contact_id)); + assert_ne!(contact_id.unwrap(), alice_contact_id); } else { bail!("Wrong QR code type"); } let qr = check_qr( - &ctx.ctx, + bob, &format!( "OPENPGP4FPR:{}#a=alice@example.org", - pub_key.dc_fingerprint() + alice_contact.fingerprint().unwrap() ), ) .await?; @@ -408,14 +383,14 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> { bail!("Wrong QR code type"); } - assert_eq!( + assert!(matches!( check_qr( - &ctx.ctx, + bob, "OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org", ) .await?, - Qr::FprMismatch { contact_id: None } - ); + Qr::FprMismatch { .. } + )); Ok(()) } diff --git a/src/reaction.rs b/src/reaction.rs index 545b31ba8..a612eb862 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -981,10 +981,11 @@ Here's my footer -- bob@example.net" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_send_reaction_multidevice() -> Result<()> { - let alice0 = TestContext::new_alice().await; - let alice1 = TestContext::new_alice().await; - let bob_id = Contact::create(&alice0, "", "bob@example.net").await?; - let chat_id = ChatId::create_for_contact(&alice0, bob_id).await?; + let mut tcm = TestContextManager::new(); + let alice0 = tcm.alice().await; + let alice1 = tcm.alice().await; + let bob = tcm.bob().await; + let chat_id = alice0.create_chat(&bob).await.id; let alice0_msg_id = send_text_msg(&alice0, chat_id, "foo".to_string()).await?; let alice1_msg = alice1.recv_msg(&alice0.pop_sent_msg().await).await; diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 479fd25b0..e0264d605 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1,22 +1,23 @@ //! Internet Message Format reception pipeline. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::iter; use std::sync::LazyLock; use anyhow::{Context as _, Result}; use data_encoding::BASE32_NOPAD; -use deltachat_contact_tools::{addr_cmp, may_be_valid_addr, sanitize_single_line, ContactAddress}; +use deltachat_contact_tools::{ + addr_cmp, addr_normalize, may_be_valid_addr, sanitize_single_line, ContactAddress, +}; use iroh_gossip::proto::TopicId; use mailparse::SingleInfo; use num_traits::FromPrimitive; use regex::Regex; -use crate::aheader::EncryptPreference; use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus}; use crate::config::Config; use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH, EDITED_PREFIX}; -use crate::contact::{Contact, ContactId, Origin}; +use crate::contact::{mark_contact_id_as_verified, Contact, ContactId, Origin}; use crate::context::Context; use crate::debug_logging::maybe_set_logging_xdc_inner; use crate::download::DownloadState; @@ -24,7 +25,8 @@ use crate::ephemeral::{stock_ephemeral_timer_changed, Timer as EphemeralTimer}; use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::imap::{markseen_on_imap_table, GENERATED_PREFIX}; -use crate::key::DcKey; +use crate::key::self_fingerprint_opt; +use crate::key::{DcKey, Fingerprint, SignedPublicKey}; use crate::log::LogExt; use crate::log::{info, warn}; use crate::message::{ @@ -33,7 +35,6 @@ use crate::message::{ use crate::mimeparser::{parse_message_ids, AvatarAction, MimeMessage, SystemMessage}; use crate::param::{Param, Params}; use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub}; -use crate::peerstate::Peerstate; use crate::reaction::{set_msg_reaction, Reaction}; use crate::rusqlite::OptionalExtension; use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on_other_device}; @@ -67,11 +68,77 @@ pub struct ReceivedMsg { /// Whether IMAP messages should be immediately deleted. pub needs_delete_job: bool, +} - /// Whether the From address was repeated in the signed part - /// (and we know that the signer intended to send from this address). - #[cfg(test)] - pub(crate) from_is_signed: bool, +/// Decision on which kind of chat the message +/// should be assigned in. +/// +/// This is done before looking up contact IDs +/// so we know in advance whether to lookup +/// key-contacts or email address contacts. +/// +/// Once this decision is made, +/// it should not be changed so we +/// don't assign the message to an encrypted +/// group after looking up key-contacts +/// or vice versa. +#[derive(Debug)] +enum ChatAssignment { + /// Trash the message. + Trash, + + /// Group chat with a Group ID. + /// + /// Lookup key-contacts and + /// assign to encrypted group. + GroupChat { grpid: String }, + + /// Mailing list or broadcast list. + /// + /// Mailing lists don't have members. + /// Broadcast lists have members + /// on the sender side, + /// but their addresses don't go into + /// the `To` field. + /// + /// In any case, the `To` + /// field should be ignored + /// and no contact IDs should be looked + /// up except the `from_id` + /// which may be an email address contact + /// or a key-contact. + MailingList, + + /// Group chat without a Group ID. + /// + /// This is not encrypted. + AdHocGroup, + + /// Assign the message to existing chat + /// with a known `chat_id`. + ExistingChat { + /// ID of existing chat + /// which the message should be assigned to. + chat_id: ChatId, + + /// Whether existing chat is blocked. + /// This is loaded together with a chat ID + /// reduce the number of database calls. + /// + /// We may want to unblock the chat + /// after adding the message there + /// if the chat is currently blocked. + chat_id_blocked: Blocked, + }, + + /// 1:1 chat with a single contact. + /// + /// The chat may be encrypted or not, + /// it does not matter. + /// It is not possible to mix + /// email address contacts + /// with key-contacts in a single 1:1 chat anyway. + OneOneChat, } /// Emulates reception of a message from the network. @@ -147,6 +214,243 @@ async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result Ok(msg_id) } +async fn get_to_and_past_contact_ids( + context: &Context, + mime_parser: &MimeMessage, + chat_assignment: &ChatAssignment, + is_partial_download: Option, + parent_message: &Option, + incoming_origin: Origin, +) -> Result<(Vec>, Vec>)> { + // `None` means that the chat is encrypted, + // but we were not able to convert the address + // to key-contact, e.g. + // because there was no corresponding + // Autocrypt-Gossip header. + // + // This way we still preserve remaining + // number of contacts and their positions + // so we can match the contacts to + // e.g. Chat-Group-Member-Timestamps + // header. + let to_ids: Vec>; + let past_ids: Vec>; + + // ID of the chat to look up the addresses in. + // + // Note that this is not necessarily the chat we want to assign the message to. + // In case of an outgoing private reply to a group message we may + // lookup the address of receipient in the list of addresses used in the group, + // but want to assign the message to 1:1 chat. + let chat_id = match chat_assignment { + ChatAssignment::Trash => None, + ChatAssignment::GroupChat { ref grpid } => { + if let Some((chat_id, _protected, _blocked)) = + chat::get_chat_id_by_grpid(context, grpid).await? + { + Some(chat_id) + } else { + None + } + } + ChatAssignment::AdHocGroup => { + // If we are going to assign a message to ad hoc group, + // we can just convert the email addresses + // to e-mail address contacts and don't need a `ChatId` + // to lookup key-contacts. + None + } + ChatAssignment::ExistingChat { chat_id, .. } => Some(*chat_id), + ChatAssignment::MailingList => None, + ChatAssignment::OneOneChat => { + if is_partial_download.is_none() && !mime_parser.incoming { + parent_message.as_ref().map(|m| m.chat_id) + } else { + None + } + } + }; + + let member_fingerprints = mime_parser.chat_group_member_fingerprints(); + let to_member_fingerprints; + let past_member_fingerprints; + + if !member_fingerprints.is_empty() { + if member_fingerprints.len() >= mime_parser.recipients.len() { + (to_member_fingerprints, past_member_fingerprints) = + member_fingerprints.split_at(mime_parser.recipients.len()); + } else { + warn!( + context, + "Unexpected length of the fingerprint header, expected at least {}, got {}.", + mime_parser.recipients.len(), + member_fingerprints.len() + ); + to_member_fingerprints = &[]; + past_member_fingerprints = &[]; + } + } else { + to_member_fingerprints = &[]; + past_member_fingerprints = &[]; + } + + let pgp_to_ids = add_or_lookup_key_contacts_by_address_list( + context, + &mime_parser.recipients, + &mime_parser.gossiped_keys, + to_member_fingerprints, + Origin::Hidden, + ) + .await?; + + match chat_assignment { + ChatAssignment::GroupChat { .. } => { + to_ids = pgp_to_ids; + + if let Some(chat_id) = chat_id { + past_ids = lookup_key_contacts_by_address_list( + context, + &mime_parser.past_members, + past_member_fingerprints, + Some(chat_id), + ) + .await?; + } else { + past_ids = add_or_lookup_key_contacts_by_address_list( + context, + &mime_parser.past_members, + &mime_parser.gossiped_keys, + past_member_fingerprints, + Origin::Hidden, + ) + .await?; + } + } + ChatAssignment::Trash | ChatAssignment::MailingList => { + to_ids = Vec::new(); + past_ids = Vec::new(); + } + ChatAssignment::ExistingChat { chat_id, .. } => { + let chat = Chat::load_from_db(context, *chat_id).await?; + if chat.is_encrypted(context).await? { + to_ids = pgp_to_ids; + past_ids = lookup_key_contacts_by_address_list( + context, + &mime_parser.past_members, + past_member_fingerprints, + Some(*chat_id), + ) + .await?; + } else { + to_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.recipients, + if !mime_parser.incoming { + Origin::OutgoingTo + } else if incoming_origin.is_known() { + Origin::IncomingTo + } else { + Origin::IncomingUnknownTo + }, + ) + .await?; + + past_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.past_members, + Origin::Hidden, + ) + .await?; + } + } + ChatAssignment::AdHocGroup => { + to_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.recipients, + if !mime_parser.incoming { + Origin::OutgoingTo + } else if incoming_origin.is_known() { + Origin::IncomingTo + } else { + Origin::IncomingUnknownTo + }, + ) + .await?; + + past_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.past_members, + Origin::Hidden, + ) + .await?; + } + ChatAssignment::OneOneChat => { + if pgp_to_ids + .first() + .is_some_and(|contact_id| contact_id.is_some()) + { + // There is a single recipient and we have + // mapped it to a key contact. + // This is an encrypted 1:1 chat. + to_ids = pgp_to_ids + } else if let Some(chat_id) = chat_id { + to_ids = lookup_key_contacts_by_address_list( + context, + &mime_parser.recipients, + to_member_fingerprints, + Some(chat_id), + ) + .await?; + } else { + let ids = match mime_parser.was_encrypted() { + true => { + lookup_key_contacts_by_address_list( + context, + &mime_parser.recipients, + to_member_fingerprints, + chat_id, + ) + .await? + } + false => vec![], + }; + if chat_id.is_some() + || (mime_parser.was_encrypted() && !ids.contains(&None)) + // Prefer creating PGP chats if there are any key-contacts. At least this prevents + // from replying unencrypted. + || ids + .iter() + .any(|&c| c.is_some() && c != Some(ContactId::SELF)) + { + to_ids = ids; + } else { + to_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.recipients, + if !mime_parser.incoming { + Origin::OutgoingTo + } else if incoming_origin.is_known() { + Origin::IncomingTo + } else { + Origin::IncomingUnknownTo + }, + ) + .await?; + } + } + + past_ids = add_or_lookup_contacts_by_address_list( + context, + &mime_parser.past_members, + Origin::Hidden, + ) + .await?; + } + }; + + Ok((to_ids, past_ids)) +} + /// Receive a message and add it to the database. /// /// Returns an error on database failure or if the message is broken, @@ -197,27 +501,11 @@ pub(crate) async fn receive_imf_inner( sort_timestamp: 0, msg_ids, needs_delete_job: false, - #[cfg(test)] - from_is_signed: false, })); } Ok(mime_parser) => mime_parser, }; - crate::peerstate::maybe_do_aeap_transition(context, &mut mime_parser).await?; - if let Some(peerstate) = &mime_parser.peerstate { - peerstate - .handle_fingerprint_change(context, mime_parser.timestamp_sent) - .await?; - // When peerstate is set to Mutual, it's saved immediately to not lose that fact in case - // of an error. Otherwise we don't save peerstate until get here to reduce the number of - // calls to save_to_db() and not to degrade encryption if a mail wasn't parsed - // successfully. - if peerstate.prefer_encrypt != EncryptPreference::Mutual { - peerstate.save_to_db(&context.sql).await?; - } - } - let rfc724_mid_orig = &mime_parser .get_rfc724_mid() .unwrap_or(rfc724_mid.to_string()); @@ -323,63 +611,82 @@ pub(crate) async fn receive_imf_inner( // For example, GitHub sends messages from `notifications@github.com`, // but uses display name of the user whose action generated the notification // as the display name. - let (from_id, _from_id_blocked, incoming_origin) = - match from_field_to_contact_id(context, &mime_parser.from, prevent_rename).await? { - Some(contact_id_res) => contact_id_res, - None => { - warn!( - context, - "receive_imf: From field does not contain an acceptable address." - ); - return Ok(None); - } - }; - - let to_ids = add_or_lookup_contacts_by_address_list( + let fingerprint = mime_parser.signatures.iter().next(); + let (from_id, _from_id_blocked, incoming_origin) = match from_field_to_contact_id( context, - &mime_parser.recipients, - if !mime_parser.incoming { - Origin::OutgoingTo - } else if incoming_origin.is_known() { - Origin::IncomingTo - } else { - Origin::IncomingUnknownTo - }, + &mime_parser.from, + fingerprint, + prevent_rename, + is_partial_download.is_some() + && mime_parser + .get_header(HeaderDef::ContentType) + .unwrap_or_default() + .starts_with("multipart/encrypted"), + ) + .await? + { + Some(contact_id_res) => contact_id_res, + None => { + warn!( + context, + "receive_imf: From field does not contain an acceptable address." + ); + return Ok(None); + } + }; + + // Lookup parent message. + // + // This may be useful to assign the message to + // group chats without Chat-Group-ID + // when a message is sent by Thunderbird. + // + // This can be also used to lookup + // key-contact by email address + // when receiving a private 1:1 reply + // to a group chat message. + let parent_message = get_parent_message( + context, + mime_parser.get_header(HeaderDef::References), + mime_parser.get_header(HeaderDef::InReplyTo), + ) + .await? + .filter(|p| Some(p.id) != replace_msg_id); + + let chat_assignment = decide_chat_assignment( + context, + &mime_parser, + &parent_message, + rfc724_mid, + from_id, + &is_partial_download, ) .await?; - let past_ids = add_or_lookup_contacts_by_address_list( + info!(context, "Chat assignment is {chat_assignment:?}."); + + let (to_ids, past_ids) = get_to_and_past_contact_ids( context, - &mime_parser.past_members, - if !mime_parser.incoming { - Origin::OutgoingTo - } else if incoming_origin.is_known() { - Origin::IncomingTo - } else { - Origin::IncomingUnknownTo - }, + &mime_parser, + &chat_assignment, + is_partial_download, + &parent_message, + incoming_origin, ) .await?; - update_verified_keys(context, &mut mime_parser, from_id).await?; - let received_msg; if mime_parser.get_header(HeaderDef::SecureJoin).is_some() { - let res; - if mime_parser.incoming { - res = handle_securejoin_handshake(context, &mut mime_parser, from_id) + let res = if mime_parser.incoming { + handle_securejoin_handshake(context, &mut mime_parser, from_id) .await - .context("error in Secure-Join message handling")?; - - // Peerstate could be updated by handling the Securejoin handshake. - let contact = Contact::get_by_id(context, from_id).await?; - mime_parser.peerstate = Peerstate::from_addr(context, contact.get_addr()).await?; + .context("error in Secure-Join message handling")? } else { - let to_id = to_ids.first().copied().unwrap_or(ContactId::SELF); + let to_id = to_ids.first().copied().flatten().unwrap_or(ContactId::SELF); // handshake may mark contacts as verified and must be processed before chats are created - res = observe_securejoin_on_other_device(context, &mime_parser, to_id) + observe_securejoin_on_other_device(context, &mime_parser, to_id) .await .context("error in Secure-Join watching")? - } + }; match res { securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => { @@ -391,8 +698,6 @@ pub(crate) async fn receive_imf_inner( sort_timestamp: mime_parser.timestamp_sent, msg_ids: vec![msg_id], needs_delete_job: res == securejoin::HandshakeMessage::Done, - #[cfg(test)] - from_is_signed: mime_parser.from_is_signed, }); } securejoin::HandshakeMessage::Propagate => { @@ -403,33 +708,67 @@ pub(crate) async fn receive_imf_inner( received_msg = None; } - let verified_encryption = has_verified_encryption(&mime_parser, from_id)?; + let verified_encryption = has_verified_encryption(context, &mime_parser, from_id).await?; if verified_encryption == VerifiedEncryption::Verified { mark_recipients_as_verified(context, from_id, &to_ids, &mime_parser).await?; } - if verified_encryption == VerifiedEncryption::Verified - && mime_parser.get_header(HeaderDef::ChatVerified).is_some() - { - if let Some(peerstate) = &mut mime_parser.peerstate { - // NOTE: it might be better to remember ID of the key - // that we used to decrypt the message, but - // it is unlikely that default key ever changes - // as it only happens when user imports a new default key. - // - // Backward verification is not security-critical, - // it is only needed to avoid adding user who does not - // have our key as verified to protected chats. - peerstate.backward_verified_key_id = - Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0); - peerstate.save_to_db(&context.sql).await?; - } - } - let received_msg = if let Some(received_msg) = received_msg { received_msg } else { + let is_dc_message = if mime_parser.has_chat_version() { + MessengerMessage::Yes + } else if let Some(parent_message) = &parent_message { + match parent_message.is_dc_message { + MessengerMessage::No => MessengerMessage::No, + MessengerMessage::Yes | MessengerMessage::Reply => MessengerMessage::Reply, + } + } else { + MessengerMessage::No + }; + + let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?) + .unwrap_or_default(); + + let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); + let allow_creation = if mime_parser.decrypting_failed { + false + } else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage + && is_dc_message == MessengerMessage::No + && !context.get_config_bool(Config::IsChatmail).await? + { + // the message is a classic email in a classic profile + // (in chatmail profiles, we always show all messages, because shared dc-mua usage is not supported) + match show_emails { + ShowEmails::Off | ShowEmails::AcceptedContacts => false, + ShowEmails::All => true, + } + } else { + !is_reaction + }; + + let to_id = if mime_parser.incoming { + ContactId::SELF + } else { + to_ids.first().copied().flatten().unwrap_or(ContactId::SELF) + }; + + let (chat_id, chat_id_blocked) = do_chat_assignment( + context, + chat_assignment, + from_id, + &to_ids, + &past_ids, + to_id, + allow_creation, + &mut mime_parser, + is_partial_download, + &verified_encryption, + parent_message, + ) + .await?; + // Add parts add_parts( context, @@ -444,6 +783,9 @@ pub(crate) async fn receive_imf_inner( replace_msg_id, prevent_rename, verified_encryption, + chat_id, + chat_id_blocked, + is_dc_message, ) .await .context("add_parts error")? @@ -663,12 +1005,22 @@ pub(crate) async fn receive_imf_inner( /// display names. We don't want the display name to change every time the user gets a new email from /// a mailing list. /// +/// * `find_key_contact_by_addr`: if true, we only know the e-mail address +/// of the contact, but not the fingerprint, +/// yet want to assign the message to some key-contact. +/// This can happen during prefetch or when the message is partially downloaded. +/// If we get it wrong, the message will be placed into the correct +/// chat after downloading. +/// /// Returns `None` if From field does not contain a valid contact address. pub async fn from_field_to_contact_id( context: &Context, from: &SingleInfo, + fingerprint: Option<&Fingerprint>, prevent_rename: bool, + find_key_contact_by_addr: bool, ) -> Result> { + let fingerprint = fingerprint.as_ref().map(|fp| fp.hex()).unwrap_or_default(); let display_name = if prevent_rename { Some("") } else { @@ -685,10 +1037,42 @@ pub async fn from_field_to_contact_id( } }; - let (from_id, _) = Contact::add_or_lookup( + if fingerprint.is_empty() && find_key_contact_by_addr { + let addr_normalized = addr_normalize(&from_addr); + + // Try to assign to some key-contact. + if let Some((from_id, origin)) = context + .sql + .query_row_optional( + "SELECT id, origin FROM contacts + WHERE addr=?1 COLLATE NOCASE + AND fingerprint<>'' -- Only key-contacts + AND id>?2 AND origin>=?3 AND blocked=?4 + ORDER BY last_seen DESC + LIMIT 1", + ( + &addr_normalized, + ContactId::LAST_SPECIAL, + Origin::IncomingUnknownFrom, + Blocked::Not, + ), + |row| { + let id: ContactId = row.get(0)?; + let origin: Origin = row.get(1)?; + Ok((id, origin)) + }, + ) + .await? + { + return Ok(Some((from_id, false, origin))); + } + } + + let (from_id, _) = Contact::add_or_lookup_ex( context, display_name.unwrap_or_default(), &from_addr, + &fingerprint, Origin::IncomingUnknownFrom, ) .await?; @@ -699,126 +1083,200 @@ pub async fn from_field_to_contact_id( let contact = Contact::get_by_id(context, from_id).await?; let from_id_blocked = contact.blocked; let incoming_origin = contact.origin; + + context + .sql + .execute( + "UPDATE contacts SET addr=? WHERE id=?", + (from_addr, from_id), + ) + .await?; + Ok(Some((from_id, from_id_blocked, incoming_origin))) } } -/// Creates a `ReceivedMsg` from given parts which might consist of -/// multiple messages (if there are multiple attachments). -/// Every entry in `mime_parser.parts` produces a new row in the `msgs` table. -#[expect(clippy::too_many_arguments)] -async fn add_parts( +async fn decide_chat_assignment( context: &Context, - mime_parser: &mut MimeMessage, - imf_raw: &[u8], - to_ids: &[ContactId], - past_ids: &[ContactId], + mime_parser: &MimeMessage, + parent_message: &Option, rfc724_mid: &str, from_id: ContactId, - seen: bool, + is_partial_download: &Option, +) -> Result { + let should_trash = if !mime_parser.mdn_reports.is_empty() { + info!(context, "Message is an MDN (TRASH)."); + true + } else if mime_parser.delivery_report.is_some() { + info!(context, "Message is a DSN (TRASH)."); + markseen_on_imap_table(context, rfc724_mid).await.ok(); + true + } else if mime_parser.get_header(HeaderDef::ChatEdit).is_some() + || mime_parser.get_header(HeaderDef::ChatDelete).is_some() + || mime_parser.get_header(HeaderDef::IrohNodeAddr).is_some() + || mime_parser.sync_items.is_some() + { + info!(context, "Chat edit/delete/iroh/sync message (TRASH)."); + true + } else if mime_parser.decrypting_failed && !mime_parser.incoming { + // Outgoing undecryptable message. + let last_time = context + .get_config_i64(Config::LastCantDecryptOutgoingMsgs) + .await?; + let now = tools::time(); + let update_config = if last_time.saturating_add(24 * 60 * 60) <= now { + let mut msg = Message::new_text(stock_str::cant_decrypt_outgoing_msgs(context).await); + chat::add_device_msg(context, None, Some(&mut msg)) + .await + .log_err(context) + .ok(); + true + } else { + last_time > now + }; + if update_config { + context + .set_config_internal(Config::LastCantDecryptOutgoingMsgs, Some(&now.to_string())) + .await?; + } + info!(context, "Outgoing undecryptable message (TRASH)."); + true + } else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage + && !mime_parser.has_chat_version() + && parent_message + .as_ref() + .is_none_or(|p| p.is_dc_message == MessengerMessage::No) + && !context.get_config_bool(Config::IsChatmail).await? + && ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?) + .unwrap_or_default() + == ShowEmails::Off + { + info!(context, "Classical email not shown (TRASH)."); + // the message is a classic email in a classic profile + // (in chatmail profiles, we always show all messages, because shared dc-mua usage is not supported) + true + } else if mime_parser + .get_header(HeaderDef::XMozillaDraftInfo) + .is_some() + { + // Mozilla Thunderbird does not set \Draft flag on "Templates", but sets + // X-Mozilla-Draft-Info header, which can be used to detect both drafts and templates + // created by Thunderbird. + + // Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them + info!(context, "Email is probably just a draft (TRASH)."); + true + } else if mime_parser.webxdc_status_update.is_some() && mime_parser.parts.len() == 1 { + if let Some(part) = mime_parser.parts.first() { + if part.typ == Viewtype::Text && part.msg.is_empty() { + info!(context, "Message is a status update only (TRASH)."); + markseen_on_imap_table(context, rfc724_mid).await.ok(); + true + } else { + false + } + } else { + false + } + } else { + false + }; + + // Decide on the type of chat we assign the message to. + // + // The chat may not exist yet, i.e. there may be + // no database row and ChatId yet. + let mut num_recipients = mime_parser.recipients.len(); + if from_id != ContactId::SELF { + let mut has_self_addr = false; + for recipient in &mime_parser.recipients { + if context.is_self_addr(&recipient.addr).await? { + has_self_addr = true; + } + } + if !has_self_addr { + num_recipients += 1; + } + } + + let chat_assignment = if should_trash { + ChatAssignment::Trash + } else if let Some(grpid) = mime_parser.get_chat_group_id() { + if mime_parser.was_encrypted() { + ChatAssignment::GroupChat { + grpid: grpid.to_string(), + } + } else if let Some(parent) = &parent_message { + if let Some((chat_id, chat_id_blocked)) = + lookup_chat_by_reply(context, mime_parser, parent, is_partial_download).await? + { + // Try to assign to a chat based on In-Reply-To/References. + ChatAssignment::ExistingChat { + chat_id, + chat_id_blocked, + } + } else { + ChatAssignment::AdHocGroup + } + } else { + // Could be a message from old version + // with opportunistic encryption. + // + // We still want to assign this to a group + // even if it had only two members. + // + // Group ID is ignored, however. + ChatAssignment::AdHocGroup + } + } else if mime_parser.get_mailinglist_header().is_some() { + ChatAssignment::MailingList + } else if let Some(parent) = &parent_message { + if let Some((chat_id, chat_id_blocked)) = + lookup_chat_by_reply(context, mime_parser, parent, is_partial_download).await? + { + // Try to assign to a chat based on In-Reply-To/References. + ChatAssignment::ExistingChat { + chat_id, + chat_id_blocked, + } + } else if num_recipients <= 1 { + ChatAssignment::OneOneChat + } else { + ChatAssignment::AdHocGroup + } + } else if num_recipients <= 1 { + ChatAssignment::OneOneChat + } else { + ChatAssignment::AdHocGroup + }; + Ok(chat_assignment) +} + +/// Assigns the message to a chat. +/// +/// Creates a new chat if necessary. +#[expect(clippy::too_many_arguments)] +async fn do_chat_assignment( + context: &Context, + chat_assignment: ChatAssignment, + from_id: ContactId, + to_ids: &[Option], + past_ids: &[Option], + to_id: ContactId, + allow_creation: bool, + mime_parser: &mut MimeMessage, is_partial_download: Option, - mut replace_msg_id: Option, - prevent_rename: bool, - verified_encryption: VerifiedEncryption, -) -> Result { + verified_encryption: &VerifiedEncryption, + parent_message: Option, +) -> Result<(ChatId, Blocked)> { let is_bot = context.get_config_bool(Config::Bot).await?; - let rfc724_mid_orig = &mime_parser - .get_rfc724_mid() - .unwrap_or(rfc724_mid.to_string()); let mut chat_id = None; let mut chat_id_blocked = Blocked::Not; - let mut better_msg = None; - let mut group_changes = GroupChangesInfo::default(); - if mime_parser.is_system_message == SystemMessage::LocationStreamingEnabled { - better_msg = Some(stock_str::msg_location_enabled_by(context, from_id).await); - } - - let parent = get_parent_message( - context, - mime_parser.get_header(HeaderDef::References), - mime_parser.get_header(HeaderDef::InReplyTo), - ) - .await? - .filter(|p| Some(p.id) != replace_msg_id); - - let is_dc_message = if mime_parser.has_chat_version() { - MessengerMessage::Yes - } else if let Some(parent) = &parent { - match parent.is_dc_message { - MessengerMessage::No => MessengerMessage::No, - MessengerMessage::Yes | MessengerMessage::Reply => MessengerMessage::Reply, - } - } else { - MessengerMessage::No - }; - // incoming non-chat messages may be discarded - - let is_location_kml = mime_parser.location_kml.is_some(); - let is_mdn = !mime_parser.mdn_reports.is_empty(); - let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); - let show_emails = - ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default(); - - let allow_creation; - if mime_parser.decrypting_failed { - allow_creation = false; - } else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage - && is_dc_message == MessengerMessage::No - && !context.get_config_bool(Config::IsChatmail).await? - { - // the message is a classic email in a classic profile - // (in chatmail profiles, we always show all messages, because shared dc-mua usage is not supported) - match show_emails { - ShowEmails::Off => { - info!(context, "Classical email not shown (TRASH)."); - chat_id = Some(DC_CHAT_ID_TRASH); - allow_creation = false; - } - ShowEmails::AcceptedContacts => allow_creation = false, - ShowEmails::All => allow_creation = !is_mdn, - } - } else { - allow_creation = !is_mdn && !is_reaction; - } - - // check if the message introduces a new chat: - // - outgoing messages introduce a chat with the first to: address if they are sent by a messenger - // - incoming messages introduce a chat only for known contacts if they are sent by a messenger - // (of course, the user can add other chats manually later) - let to_id: ContactId; - let state: MessageState; - let mut hidden = is_reaction; - let mut needs_delete_job = false; - let mut restore_protection = false; - - // if contact renaming is prevented (for mailinglists and bots), - // we use name from From:-header as override name - if prevent_rename { - if let Some(name) = &mime_parser.from.display_name { - for part in &mut mime_parser.parts { - part.param.set(Param::OverrideSenderDisplayname, name); - } - } - } - - if chat_id.is_none() && is_mdn { - chat_id = Some(DC_CHAT_ID_TRASH); - info!(context, "Message is an MDN (TRASH).",); - } - if mime_parser.incoming { - to_id = ContactId::SELF; - let test_normal_chat = ChatIdBlocked::lookup_by_contact(context, from_id).await?; - if chat_id.is_none() && mime_parser.delivery_report.is_some() { - chat_id = Some(DC_CHAT_ID_TRASH); - info!(context, "Message is a DSN (TRASH).",); - markseen_on_imap_table(context, rfc724_mid).await.ok(); - } - let create_blocked_default = if is_bot { Blocked::Not } else { @@ -845,11 +1303,14 @@ async fn add_parts( create_blocked_default }; - // Try to assign to a chat based on Chat-Group-ID. - if chat_id.is_none() { - if let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) { + match &chat_assignment { + ChatAssignment::Trash => { + chat_id = Some(DC_CHAT_ID_TRASH); + } + ChatAssignment::GroupChat { grpid } => { + // Try to assign to a chat based on Chat-Group-ID. if let Some((id, _protected, blocked)) = - chat::get_chat_id_by_grpid(context, &grpid).await? + chat::get_chat_id_by_grpid(context, grpid).await? { chat_id = Some(id); chat_id_blocked = blocked; @@ -862,8 +1323,8 @@ async fn add_parts( from_id, to_ids, past_ids, - &verified_encryption, - &grpid, + verified_encryption, + grpid, ) .await? { @@ -872,80 +1333,39 @@ async fn add_parts( } } } - } + ChatAssignment::MailingList => { + if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { + if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_mailinglist( + context, + allow_creation, + mailinglist_header, + mime_parser, + ) + .await? + { + chat_id = Some(new_chat_id); + chat_id_blocked = new_chat_id_blocked; - if chat_id.is_none() { - if let Some((new_chat_id, new_chat_id_blocked)) = lookup_chat_or_create_adhoc_group( - context, - mime_parser, - &parent, - to_ids, - from_id, - allow_creation || test_normal_chat.is_some(), - create_blocked, - is_partial_download.is_some(), - ) - .await? - { - chat_id = Some(new_chat_id); - chat_id_blocked = new_chat_id_blocked; - } - } - - // if the chat is somehow blocked but we want to create a non-blocked chat, - // unblock the chat - if chat_id_blocked != Blocked::Not && create_blocked != Blocked::Yes { - if let Some(chat_id) = chat_id { - chat_id.set_blocked(context, create_blocked).await?; - chat_id_blocked = create_blocked; - } - } - - // In lookup_chat_by_reply() and create_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(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() && chat.typ == Chattype::Single { - // Just assign the message to the 1:1 chat with the actual sender instead. - chat_id = None; - } 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. - let from = &mime_parser.from; - let name: &str = from.display_name.as_ref().unwrap_or(&from.addr); - for part in &mut mime_parser.parts { - part.param.set(Param::OverrideSenderDisplayname, name); - - if chat.is_protected() { - // In protected chat, also mark the message with an error. - let s = stock_str::unknown_sender_for_chat(context).await; - part.error = Some(s); - } + apply_mailinglist_changes(context, mime_parser, new_chat_id).await?; } } } - - group_changes = apply_group_changes( - context, - mime_parser, - group_chat_id, - from_id, - to_ids, - past_ids, - &verified_encryption, - ) - .await?; - } - - if chat_id.is_none() { - // check if the message belongs to a mailing list - if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { - if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_mailinglist( + ChatAssignment::ExistingChat { + chat_id: new_chat_id, + chat_id_blocked: new_chat_id_blocked, + } => { + chat_id = Some(*new_chat_id); + chat_id_blocked = *new_chat_id_blocked; + } + ChatAssignment::AdHocGroup => { + if let Some((new_chat_id, new_chat_id_blocked)) = lookup_or_create_adhoc_group( context, - allow_creation, - mailinglist_header, mime_parser, + to_ids, + from_id, + allow_creation || test_normal_chat.is_some(), + create_blocked, + is_partial_download.is_some(), ) .await? { @@ -953,14 +1373,23 @@ async fn add_parts( chat_id_blocked = new_chat_id_blocked; } } + ChatAssignment::OneOneChat => {} } - if let Some(chat_id) = chat_id { - apply_mailinglist_changes(context, mime_parser, chat_id).await?; + // if the chat is somehow blocked but we want to create a non-blocked chat, + // unblock the chat + if chat_id_blocked != Blocked::Not + && create_blocked != Blocked::Yes + && !matches!(chat_assignment, ChatAssignment::MailingList) + { + if let Some(chat_id) = chat_id { + chat_id.set_blocked(context, create_blocked).await?; + chat_id_blocked = create_blocked; + } } if chat_id.is_none() { - // try to create a normal chat + // Try to create a 1:1 chat. let contact = Contact::get_by_id(context, from_id).await?; let create_blocked = match contact.is_blocked() { true => Blocked::Yes, @@ -984,7 +1413,7 @@ async fn add_parts( if chat_id_blocked != create_blocked { chat_id.set_blocked(context, create_blocked).await?; } - if create_blocked == Blocked::Request && parent.is_some() { + if create_blocked == Blocked::Request && parent_message.is_some() { // we do not want any chat to be created implicitly. Because of the origin-scale-up, // the contact requests will pop up and this should be just fine. ContactId::scaleup_origin(context, &[from_id], Origin::IncomingReplyTo) @@ -1033,60 +1462,25 @@ async fn add_parts( ) .await?; } - if let Some(peerstate) = &mime_parser.peerstate { - restore_protection = new_protection != ProtectionStatus::Protected - && peerstate.prefer_encrypt == EncryptPreference::Mutual - // Check that the contact still has the Autocrypt key same as the - // verified key, see also `Peerstate::is_using_verified_key()`. - && contact.is_verified(context).await?; - } } } } - - state = if seen || is_mdn || chat_id_blocked == Blocked::Yes || group_changes.silent - // No check for `hidden` because only reactions are such and they should be `InFresh`. - { - MessageState::InSeen - } else { - MessageState::InFresh - }; } else { // Outgoing - // the mail is on the IMAP server, probably it is also delivered. - // We cannot recreate other states (read, error). - state = MessageState::OutDelivered; - to_id = to_ids.first().copied().unwrap_or(ContactId::SELF); - // Older Delta Chat versions with core <=1.152.2 only accepted // self-sent messages in Saved Messages with own address in the `To` field. // New Delta Chat versions may use empty `To` field // with only a single `hidden-recipients` group in this case. let self_sent = to_ids.len() <= 1 && to_id == ContactId::SELF; - if mime_parser.sync_items.is_some() && self_sent { - chat_id = Some(DC_CHAT_ID_TRASH); - } - - // Mozilla Thunderbird does not set \Draft flag on "Templates", but sets - // X-Mozilla-Draft-Info header, which can be used to detect both drafts and templates - // created by Thunderbird. - let is_draft = mime_parser - .get_header(HeaderDef::XMozillaDraftInfo) - .is_some(); - - if is_draft { - // Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them - info!(context, "Email is probably just a draft (TRASH)."); - chat_id = Some(DC_CHAT_ID_TRASH); - } - - // Try to assign to a chat based on Chat-Group-ID. - if chat_id.is_none() { - if let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) { + match &chat_assignment { + ChatAssignment::Trash => { + chat_id = Some(DC_CHAT_ID_TRASH); + } + ChatAssignment::GroupChat { grpid } => { if let Some((id, _protected, blocked)) = - chat::get_chat_id_by_grpid(context, &grpid).await? + chat::get_chat_id_by_grpid(context, grpid).await? { chat_id = Some(id); chat_id_blocked = blocked; @@ -1099,8 +1493,8 @@ async fn add_parts( from_id, to_ids, past_ids, - &verified_encryption, - &grpid, + verified_encryption, + grpid, ) .await? { @@ -1109,55 +1503,46 @@ async fn add_parts( } } } - } - - if mime_parser.decrypting_failed { - if chat_id.is_none() { - chat_id = Some(DC_CHAT_ID_TRASH); - } else { - hidden = true; + ChatAssignment::ExistingChat { + chat_id: new_chat_id, + chat_id_blocked: new_chat_id_blocked, + } => { + chat_id = Some(*new_chat_id); + chat_id_blocked = *new_chat_id_blocked; } - let last_time = context - .get_config_i64(Config::LastCantDecryptOutgoingMsgs) - .await?; - let now = tools::time(); - let update_config = if last_time.saturating_add(24 * 60 * 60) <= now { - let mut msg = - Message::new_text(stock_str::cant_decrypt_outgoing_msgs(context).await); - chat::add_device_msg(context, None, Some(&mut msg)) - .await - .log_err(context) - .ok(); - true - } else { - last_time > now - }; - if update_config { - context - .set_config_internal( - Config::LastCantDecryptOutgoingMsgs, - Some(&now.to_string()), - ) - .await?; + ChatAssignment::MailingList => { + // Check if the message belongs to a broadcast list. + if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { + let listid = mailinglist_header_listid(mailinglist_header)?; + chat_id = Some( + if let Some((id, ..)) = chat::get_chat_id_by_grpid(context, &listid).await? + { + id + } else { + let name = + compute_mailinglist_name(mailinglist_header, &listid, mime_parser); + chat::create_broadcast_list_ex(context, Nosync, listid, name).await? + }, + ); + } } - } - - if chat_id.is_none() { - if let Some((new_chat_id, new_chat_id_blocked)) = lookup_chat_or_create_adhoc_group( - context, - mime_parser, - &parent, - to_ids, - from_id, - allow_creation, - Blocked::Not, - is_partial_download.is_some(), - ) - .await? - { - chat_id = Some(new_chat_id); - chat_id_blocked = new_chat_id_blocked; + ChatAssignment::AdHocGroup => { + if let Some((new_chat_id, new_chat_id_blocked)) = lookup_or_create_adhoc_group( + context, + mime_parser, + to_ids, + from_id, + allow_creation, + Blocked::Not, + is_partial_download.is_some(), + ) + .await? + { + chat_id = Some(new_chat_id); + chat_id_blocked = new_chat_id_blocked; + } } + ChatAssignment::OneOneChat => {} } if !to_ids.is_empty() { @@ -1176,49 +1561,12 @@ async fn add_parts( chat_id_blocked = chat.blocked; } } - if chat_id.is_none() && is_dc_message == MessengerMessage::Yes { + if chat_id.is_none() && mime_parser.has_chat_version() { if let Some(chat) = ChatIdBlocked::lookup_by_contact(context, to_id).await? { chat_id = Some(chat.id); chat_id_blocked = chat.blocked; } } - - // automatically unblock chat when the user sends a message - if chat_id_blocked != Blocked::Not { - if let Some(chat_id) = chat_id { - chat_id.unblock_ex(context, Nosync).await?; - // Not assigning `chat_id_blocked = Blocked::Not` to avoid unused_assignments warning. - } - } - } - - if let Some(chat_id) = chat_id { - group_changes = apply_group_changes( - context, - mime_parser, - chat_id, - from_id, - to_ids, - past_ids, - &verified_encryption, - ) - .await?; - } - - if chat_id.is_none() { - // Check if the message belongs to a broadcast list. - if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { - let listid = mailinglist_header_listid(mailinglist_header)?; - chat_id = Some( - if let Some((id, ..)) = chat::get_chat_id_by_grpid(context, &listid).await? { - id - } else { - let name = - compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - chat::create_broadcast_list_ex(context, Nosync, listid, name).await? - }, - ); - } } if chat_id.is_none() && self_sent { @@ -1229,29 +1577,105 @@ async fn add_parts( .context("Failed to get (new) chat for contact")?; chat_id = Some(chat.id); - // Not assigning `chat_id_blocked = chat.blocked` to avoid unused_assignments warning. + chat_id_blocked = chat.blocked; if Blocked::Not != chat.blocked { chat.id.unblock_ex(context, Nosync).await?; } } - } - if mime_parser.webxdc_status_update.is_some() && mime_parser.parts.len() == 1 { - if let Some(part) = mime_parser.parts.first() { - if part.typ == Viewtype::Text && part.msg.is_empty() { - chat_id = Some(DC_CHAT_ID_TRASH); - info!(context, "Message is a status update only (TRASH)."); - markseen_on_imap_table(context, rfc724_mid).await.ok(); + // automatically unblock chat when the user sends a message + if chat_id_blocked != Blocked::Not { + if let Some(chat_id) = chat_id { + chat_id.unblock_ex(context, Nosync).await?; + chat_id_blocked = Blocked::Not; + } + } + } + let chat_id = chat_id.unwrap_or_else(|| { + info!(context, "No chat id for message (TRASH)."); + DC_CHAT_ID_TRASH + }); + Ok((chat_id, chat_id_blocked)) +} + +/// Creates a `ReceivedMsg` from given parts which might consist of +/// multiple messages (if there are multiple attachments). +/// Every entry in `mime_parser.parts` produces a new row in the `msgs` table. +#[expect(clippy::too_many_arguments)] +async fn add_parts( + context: &Context, + mime_parser: &mut MimeMessage, + imf_raw: &[u8], + to_ids: &[Option], + past_ids: &[Option], + rfc724_mid: &str, + from_id: ContactId, + seen: bool, + is_partial_download: Option, + mut replace_msg_id: Option, + prevent_rename: bool, + verified_encryption: VerifiedEncryption, + chat_id: ChatId, + chat_id_blocked: Blocked, + is_dc_message: MessengerMessage, +) -> Result { + let to_id = if mime_parser.incoming { + ContactId::SELF + } else { + to_ids.first().copied().flatten().unwrap_or(ContactId::SELF) + }; + + // if contact renaming is prevented (for mailinglists and bots), + // we use name from From:-header as override name + if prevent_rename { + if let Some(name) = &mime_parser.from.display_name { + for part in &mut mime_parser.parts { + part.param.set(Param::OverrideSenderDisplayname, name); } } } - let orig_chat_id = chat_id; - let mut chat_id = chat_id.unwrap_or_else(|| { - info!(context, "No chat id for message (TRASH)."); - DC_CHAT_ID_TRASH - }); + if mime_parser.incoming && !chat_id.is_trash() { + // It can happen that the message is put into a chat + // but the From-address is not a member of this chat. + if !chat::is_contact_in_chat(context, chat_id, from_id).await? { + let chat = Chat::load_from_db(context, chat_id).await?; + + // Mark the sender as overridden. + // The UI will prepend `~` to the sender's name, + // indicating that the sender is not part of the group. + let from = &mime_parser.from; + let name: &str = from.display_name.as_ref().unwrap_or(&from.addr); + for part in &mut mime_parser.parts { + part.param.set(Param::OverrideSenderDisplayname, name); + + if chat.is_protected() { + // In protected chat, also mark the message with an error. + let s = stock_str::unknown_sender_for_chat(context).await; + part.error = Some(s); + } + } + } + } + + let is_location_kml = mime_parser.location_kml.is_some(); + let is_mdn = !mime_parser.mdn_reports.is_empty(); + + let mut group_changes = apply_group_changes( + context, + mime_parser, + chat_id, + from_id, + to_ids, + past_ids, + &verified_encryption, + ) + .await?; + + let rfc724_mid_orig = &mime_parser + .get_rfc724_mid() + .unwrap_or(rfc724_mid.to_string()); // Extract ephemeral timer from the message or use the existing timer if the message is not fully downloaded. let mut ephemeral_timer = if is_partial_download.is_some() { @@ -1268,7 +1692,17 @@ async fn add_parts( EphemeralTimer::Disabled }; + let state = if !mime_parser.incoming { + MessageState::OutDelivered + } else if seen || is_mdn || chat_id_blocked == Blocked::Yes || group_changes.silent + // No check for `hidden` because only reactions are such and they should be `InFresh`. + { + MessageState::InSeen + } else { + MessageState::InFresh + }; let in_fresh = state == MessageState::InFresh; + let sort_to_bottom = false; let received = true; let sort_timestamp = chat_id @@ -1357,9 +1791,10 @@ async fn add_parts( } } - if mime_parser.is_system_message == SystemMessage::EphemeralTimerChanged { - better_msg = Some(stock_ephemeral_timer_changed(context, ephemeral_timer, from_id).await); - + let mut better_msg = if mime_parser.is_system_message == SystemMessage::LocationStreamingEnabled + { + Some(stock_str::msg_location_enabled_by(context, from_id).await) + } else if mime_parser.is_system_message == SystemMessage::EphemeralTimerChanged { // Do not delete the system message itself. // // This prevents confusion when timer is changed @@ -1367,7 +1802,11 @@ async fn add_parts( // hour, only the message about the change to 1 // week is left. ephemeral_timer = EphemeralTimer::Disabled; - } + + Some(stock_ephemeral_timer_changed(context, ephemeral_timer, from_id).await) + } else { + None + }; // if a chat is protected and the message is fully downloaded, check additional properties if !chat_id.is_special() && is_partial_download.is_none() { @@ -1448,6 +1887,16 @@ async fn add_parts( } } + let chat_id = if better_msg + .as_ref() + .is_some_and(|better_msg| better_msg.is_empty()) + && is_partial_download.is_none() + { + DC_CHAT_ID_TRASH + } else { + chat_id + }; + for (group_changes_msg, cmd, added_removed_id) in group_changes.extra_msgs { chat::add_info_msg_with_cmd( context, @@ -1464,7 +1913,6 @@ async fn add_parts( } if let Some(node_addr) = mime_parser.get_header(HeaderDef::IrohNodeAddr) { - chat_id = DC_CHAT_ID_TRASH; match mime_parser.get_header(HeaderDef::InReplyTo) { Some(in_reply_to) => match rfc724_mid_exists(context, in_reply_to).await? { Some((instance_id, _ts_sent)) => { @@ -1490,11 +1938,10 @@ async fn add_parts( } } - if handle_edit_delete(context, mime_parser, from_id).await? { - chat_id = DC_CHAT_ID_TRASH; - info!(context, "Message edits/deletes existing message (TRASH)."); - } + handle_edit_delete(context, mime_parser, from_id).await?; + let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); + let hidden = is_reaction; let mut parts = mime_parser.parts.iter().peekable(); while let Some(part) = parts.next() { if part.is_reaction { @@ -1503,7 +1950,7 @@ async fn add_parts( set_msg_reaction( context, mime_in_reply_to, - orig_chat_id.unwrap_or_default(), + chat_id, from_id, sort_timestamp, Reaction::from(reaction_str.as_str()), @@ -1532,9 +1979,6 @@ async fn add_parts( } let (msg, typ): (&str, Viewtype) = if let Some(better_msg) = &better_msg { - if better_msg.is_empty() && is_partial_download.is_none() { - chat_id = DC_CHAT_ID_TRASH; - } (better_msg, Viewtype::Text) } else { (&part.msg, part.typ) @@ -1729,22 +2173,12 @@ RETURNING id } } - if !mime_parser.incoming && is_mdn && is_dc_message == MessengerMessage::Yes { - // Normally outgoing MDNs sent by us never appear in mailboxes, but Gmail saves all - // outgoing messages, including MDNs, to the Sent folder. If we detect such saved MDN, - // delete it. - needs_delete_job = true; - } - if restore_protection { - chat_id - .set_protection( - context, - ProtectionStatus::Protected, - mime_parser.timestamp_rcvd, - Some(from_id), - ) - .await?; - } + // Normally outgoing MDNs sent by us never appear in mailboxes, but Gmail saves all + // outgoing messages, including MDNs, to the Sent folder. If we detect such saved MDN, + // delete it. + let needs_delete_job = + !mime_parser.incoming && is_mdn && is_dc_message == MessengerMessage::Yes; + Ok(ReceivedMsg { chat_id, state, @@ -1752,8 +2186,6 @@ RETURNING id sort_timestamp, msg_ids: created_db_entries, needs_delete_job, - #[cfg(test)] - from_is_signed: mime_parser.from_is_signed, }) } @@ -1765,7 +2197,7 @@ async fn handle_edit_delete( context: &Context, mime_parser: &MimeMessage, from_id: ContactId, -) -> Result { +) -> Result<()> { if let Some(rfc724_mid) = mime_parser.get_header(HeaderDef::ChatEdit) { if let Some((original_msg_id, _)) = rfc724_mid_exists(context, rfc724_mid).await? { if let Some(mut original_msg) = @@ -1798,8 +2230,6 @@ async fn handle_edit_delete( "Edit message: rfc724_mid {rfc724_mid:?} not found." ); } - - Ok(true) } else if let Some(rfc724_mid_list) = mime_parser.get_header(HeaderDef::ChatDelete) { if let Some(part) = mime_parser.parts.first() { // See `message::delete_msgs_ex()`, unlike edit requests, DC doesn't send unencrypted @@ -1833,11 +2263,8 @@ async fn handle_edit_delete( warn!(context, "Delete message: Not encrypted."); } } - - Ok(true) - } else { - Ok(false) } + Ok(()) } async fn tweak_sort_timestamp( @@ -1929,61 +2356,58 @@ async fn save_locations( async fn lookup_chat_by_reply( context: &Context, mime_parser: &MimeMessage, - parent: &Option, - to_ids: &[ContactId], - from_id: ContactId, + parent: &Message, + is_partial_download: &Option, ) -> Result> { - // Try to assign message to the same chat as the parent message. + // If the message is encrypted and has group ID, + // lookup by reply should never be needed + // as we can directly assign the message to the chat + // by its group ID. + debug_assert!(mime_parser.get_chat_group_id().is_none() || !mime_parser.was_encrypted()); - let Some(parent) = parent else { - return Ok(None); - }; + // Try to assign message to the same chat as the parent message. let Some(parent_chat_id) = ChatId::lookup_by_message(parent) else { return Ok(None); }; - let parent_chat = Chat::load_from_db(context, parent_chat_id).await?; // If this was a private message just to self, it was probably a private reply. // It should not go into the group then, but into the private chat. - if is_probably_private_reply(context, to_ids, from_id, mime_parser, parent_chat.id).await? { + if is_probably_private_reply(context, mime_parser, parent_chat_id).await? { return Ok(None); } - // If the parent chat is a 1:1 chat, and the sender is a classical MUA and added + // If the parent chat is a 1:1 chat, and the sender added // a new person to TO/CC, then the message should not go to the 1:1 chat, but to a // newly created ad-hoc group. - if parent_chat.typ == Chattype::Single && !mime_parser.has_chat_version() && to_ids.len() > 1 { - let mut chat_contacts = chat::get_chat_contacts(context, parent_chat.id).await?; - chat_contacts.push(ContactId::SELF); - if to_ids.iter().any(|id| !chat_contacts.contains(id)) { - return Ok(None); - } + let parent_chat = Chat::load_from_db(context, parent_chat_id).await?; + if parent_chat.typ == Chattype::Single && mime_parser.recipients.len() > 1 { + return Ok(None); + } + + // Do not assign unencrypted messages to encrypted chats. + if is_partial_download.is_none() + && parent_chat.is_encrypted(context).await? + && !mime_parser.was_encrypted() + { + return Ok(None); } info!( context, - "Assigning message to {} as it's a reply to {}.", parent_chat.id, parent.rfc724_mid + "Assigning message to {parent_chat_id} as it's a reply to {}.", parent.rfc724_mid ); Ok(Some((parent_chat.id, parent_chat.blocked))) } -#[expect(clippy::too_many_arguments)] -async fn lookup_chat_or_create_adhoc_group( +async fn lookup_or_create_adhoc_group( context: &Context, mime_parser: &MimeMessage, - parent: &Option, - to_ids: &[ContactId], + to_ids: &[Option], from_id: ContactId, allow_creation: bool, create_blocked: Blocked, is_partial_download: bool, ) -> Result> { - if let Some((new_chat_id, new_chat_id_blocked)) = - // Try to assign to a chat based on In-Reply-To/References. - lookup_chat_by_reply(context, mime_parser, parent, to_ids, from_id).await? - { - return Ok(Some((new_chat_id, new_chat_id_blocked))); - } // Partial download may be an encrypted message with protected Subject header. We do not want to // create a group with "..." or "Encrypted message" as a subject. The same is for undecipherable // messages. Instead, assign the message to 1:1 chat with the sender. @@ -2006,8 +2430,9 @@ async fn lookup_chat_or_create_adhoc_group( .get_subject() .map(|s| remove_subject_prefix(&s)) .unwrap_or_else(|| "👥📧".to_string()); + let to_ids: Vec = to_ids.iter().filter_map(|x| *x).collect(); let mut contact_ids = Vec::with_capacity(to_ids.len() + 1); - contact_ids.extend(to_ids); + contact_ids.extend(&to_ids); if !contact_ids.contains(&from_id) { contact_ids.push(from_id); } @@ -2063,7 +2488,7 @@ async fn lookup_chat_or_create_adhoc_group( mime_parser, create_blocked, from_id, - to_ids, + &to_ids, &grpname, ) .await @@ -2074,11 +2499,14 @@ async fn lookup_chat_or_create_adhoc_group( /// If it returns false, it shall be assigned to the parent chat. async fn is_probably_private_reply( context: &Context, - to_ids: &[ContactId], - from_id: ContactId, mime_parser: &MimeMessage, parent_chat_id: ChatId, ) -> Result { + // Message cannot be a private reply if it has an explicit Chat-Group-ID header. + if mime_parser.get_chat_group_id().is_some() { + return Ok(false); + } + // Usually we don't want to show private replies in the parent chat, but in the // 1:1 chat with the sender. // @@ -2086,14 +2514,7 @@ async fn is_probably_private_reply( // should be assigned to the group chat. We restrict this exception to classical emails, as chat-group-messages // contain a Chat-Group-Id header and can be sorted into the correct chat this way. - let private_message = - (to_ids == [ContactId::SELF]) || (from_id == ContactId::SELF && to_ids.len() == 1); - if !private_message { - return Ok(false); - } - - // Message cannot be a private reply if it has an explicit Chat-Group-ID header. - if mime_parser.get_chat_group_id().is_some() { + if mime_parser.recipients.len() != 1 { return Ok(false); } @@ -2119,24 +2540,15 @@ async fn create_group( is_partial_download: bool, create_blocked: Blocked, from_id: ContactId, - to_ids: &[ContactId], - past_ids: &[ContactId], + to_ids: &[Option], + past_ids: &[Option], verified_encryption: &VerifiedEncryption, grpid: &str, ) -> Result> { + let to_ids_flat: Vec = to_ids.iter().filter_map(|x| *x).collect(); let mut chat_id = None; let mut chat_id_blocked = Default::default(); - // For chat messages, we don't have to guess (is_*probably*_private_reply()) but we know for sure that - // they belong to the group because of the Chat-Group-Id or Message-Id header - if let Some(chat_id) = chat_id { - if !mime_parser.has_chat_version() - && is_probably_private_reply(context, to_ids, from_id, mime_parser, chat_id).await? - { - return Ok(None); - } - } - let create_protected = if mime_parser.get_header(HeaderDef::ChatVerified).is_some() { if let VerifiedEncryption::NotVerified(err) = verified_encryption { warn!( @@ -2199,8 +2611,8 @@ async fn create_group( // Create initial member list. if let Some(mut chat_group_member_timestamps) = mime_parser.chat_group_member_timestamps() { let mut new_to_ids = to_ids.to_vec(); - if !new_to_ids.contains(&from_id) { - new_to_ids.insert(0, from_id); + if !new_to_ids.contains(&Some(from_id)) { + new_to_ids.insert(0, Some(from_id)); chat_group_member_timestamps.insert(0, mime_parser.timestamp_sent); } @@ -2218,7 +2630,7 @@ async fn create_group( if !from_id.is_special() { members.push(from_id); } - members.extend(to_ids); + members.extend(to_ids_flat); // Add all members with 0 timestamp // because we don't know the real timestamp of their addition. @@ -2257,8 +2669,8 @@ async fn update_chats_contacts_timestamps( context: &Context, chat_id: ChatId, ignored_id: Option, - to_ids: &[ContactId], - past_ids: &[ContactId], + to_ids: &[Option], + past_ids: &[Option], chat_group_member_timestamps: &[i64], ) -> Result { let expected_timestamps_count = to_ids.len() + past_ids.len(); @@ -2291,11 +2703,13 @@ async fn update_chats_contacts_timestamps( to_ids.iter(), chat_group_member_timestamps.iter().take(to_ids.len()), ) { - if Some(*contact_id) != ignored_id { - // It could be that member was already added, - // but updated addition timestamp - // is also a modification worth notifying about. - modified |= add_statement.execute((chat_id, contact_id, ts))? > 0; + if let Some(contact_id) = contact_id { + if Some(*contact_id) != ignored_id { + // It could be that member was already added, + // but updated addition timestamp + // is also a modification worth notifying about. + modified |= add_statement.execute((chat_id, contact_id, ts))? > 0; + } } } @@ -2312,10 +2726,12 @@ async fn update_chats_contacts_timestamps( past_ids.iter(), chat_group_member_timestamps.iter().skip(to_ids.len()), ) { - // It could be that member was already removed, - // but updated removal timestamp - // is also a modification worth notifying about. - modified |= remove_statement.execute((chat_id, contact_id, ts))? > 0; + if let Some(contact_id) = contact_id { + // It could be that member was already removed, + // but updated removal timestamp + // is also a modification worth notifying about. + modified |= remove_statement.execute((chat_id, contact_id, ts))? > 0; + } } Ok(()) @@ -2352,10 +2768,11 @@ async fn apply_group_changes( mime_parser: &mut MimeMessage, chat_id: ChatId, from_id: ContactId, - to_ids: &[ContactId], - past_ids: &[ContactId], + to_ids: &[Option], + past_ids: &[Option], verified_encryption: &VerifiedEncryption, ) -> Result { + let to_ids_flat: Vec = to_ids.iter().filter_map(|x| *x).collect(); if chat_id.is_special() { // Do not apply group changes to the trash chat. return Ok(GroupChangesInfo::default()); @@ -2408,27 +2825,46 @@ async fn apply_group_changes( } if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { - removed_id = Contact::lookup_id_by_addr(context, removed_addr, Origin::Unknown).await?; + // TODO: if address "alice@example.org" is a member of the group twice, + // with old and new key, + // and someone (maybe Alice's new contact) just removed Alice's old contact, + // we may lookup the wrong contact because we only look up by the address. + // The result is that info message may contain the new Alice's display name + // rather than old display name. + // This could be fixed by looking up the contact with the highest + // `remove_timestamp` after applying Chat-Group-Member-Timestamps. + removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat_id)).await?; if let Some(id) = removed_id { better_msg = if id == from_id { silent = true; Some(stock_str::msg_group_left_local(context, from_id).await) } else { - Some(stock_str::msg_del_member_local(context, removed_addr, from_id).await) + Some(stock_str::msg_del_member_local(context, id, from_id).await) }; } else { warn!(context, "Removed {removed_addr:?} has no contact id.") } } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - if let Some(contact_id) = - Contact::lookup_id_by_addr(context, added_addr, Origin::Unknown).await? - { - added_id = Some(contact_id); + if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { + // TODO: if gossiped keys contain the same address multiple times, + // we may lookup the wrong contact. + // This could be fixed by looking up the contact with + // highest `add_timestamp` to disambiguate. + // The result of the error is that info message + // may contain display name of the wrong contact. + let fingerprint = key.dc_fingerprint().hex(); + if let Some(contact_id) = + lookup_key_contact_by_fingerprint(context, &fingerprint).await? + { + added_id = Some(contact_id); + better_msg = + Some(stock_str::msg_add_member_local(context, contact_id, from_id).await); + } else { + warn!(context, "Added {added_addr:?} has no contact id."); + } } else { - warn!(context, "Added {added_addr:?} has no contact id."); + warn!(context, "Added {added_addr:?} has no gossiped key."); } - - better_msg = Some(stock_str::msg_add_member_local(context, added_addr, from_id).await); } let group_name_timestamp = mime_parser @@ -2499,7 +2935,8 @@ async fn apply_group_changes( if is_from_in_chat { if chat.member_list_is_stale(context).await? { info!(context, "Member list is stale."); - let mut new_members: HashSet = HashSet::from_iter(to_ids.iter().copied()); + let mut new_members: HashSet = + HashSet::from_iter(to_ids_flat.iter().copied()); new_members.insert(ContactId::SELF); if !from_id.is_special() { new_members.insert(from_id); @@ -2541,9 +2978,9 @@ async fn apply_group_changes( ) .await?; } else { - let mut new_members; + let mut new_members: HashSet; if self_added { - new_members = HashSet::from_iter(to_ids.iter().copied()); + new_members = HashSet::from_iter(to_ids_flat.iter().copied()); new_members.insert(ContactId::SELF); if !from_id.is_special() { new_members.insert(from_id); @@ -2556,7 +2993,7 @@ async fn apply_group_changes( if mime_parser.get_header(HeaderDef::ChatVersion).is_none() { // Don't delete any members locally, but instead add absent ones to provide group // membership consistency for all members: - new_members.extend(to_ids.iter()); + new_members.extend(to_ids_flat.iter()); } // Apply explicit addition if any. @@ -2678,7 +3115,7 @@ async fn group_changes_msgs( removed_ids: &HashSet, chat_id: ChatId, ) -> Result)>> { - let mut group_changes_msgs = Vec::new(); + let mut group_changes_msgs: Vec<(String, SystemMessage, Option)> = Vec::new(); if !added_ids.is_empty() { warn!( context, @@ -2693,21 +3130,17 @@ async fn group_changes_msgs( } group_changes_msgs.reserve(added_ids.len() + removed_ids.len()); for contact_id in added_ids { - let contact = Contact::get_by_id(context, *contact_id).await?; group_changes_msgs.push(( - stock_str::msg_add_member_local(context, contact.get_addr(), ContactId::UNDEFINED) - .await, + stock_str::msg_add_member_local(context, *contact_id, ContactId::UNDEFINED).await, SystemMessage::MemberAddedToGroup, - Some(contact.id), + Some(*contact_id), )); } for contact_id in removed_ids { - let contact = Contact::get_by_id(context, *contact_id).await?; group_changes_msgs.push(( - stock_str::msg_del_member_local(context, contact.get_addr(), ContactId::UNDEFINED) - .await, + stock_str::msg_del_member_local(context, *contact_id, ContactId::UNDEFINED).await, SystemMessage::MemberRemovedFromGroup, - Some(contact.id), + Some(*contact_id), )); } @@ -2964,7 +3397,11 @@ async fn create_adhoc_group( ); return Ok(Some((DC_CHAT_ID_TRASH, Blocked::Not))); } - if member_ids.len() < 3 { + if member_ids.len() < 2 { + info!( + context, + "Not creating ad hoc group with less than 2 members." + ); return Ok(None); } @@ -3005,70 +3442,11 @@ enum VerifiedEncryption { NotVerified(String), // The string contains the reason why it's not verified } -/// Moves secondary verified key to primary verified key -/// if the message is signed with a secondary verified key. -/// Removes secondary verified key if the message is signed with primary key. -async fn update_verified_keys( - context: &Context, - mimeparser: &mut MimeMessage, - from_id: ContactId, -) -> Result> { - if from_id == ContactId::SELF { - return Ok(None); - } - - if !mimeparser.was_encrypted() { - return Ok(None); - } - - let Some(peerstate) = &mut mimeparser.peerstate else { - // No peerstate means no verified keys. - return Ok(None); - }; - - let signed_with_primary_verified_key = peerstate - .verified_key_fingerprint - .as_ref() - .filter(|fp| mimeparser.signatures.contains(fp)) - .is_some(); - let signed_with_secondary_verified_key = peerstate - .secondary_verified_key_fingerprint - .as_ref() - .filter(|fp| mimeparser.signatures.contains(fp)) - .is_some(); - - if signed_with_primary_verified_key { - // Remove secondary key if it exists. - if peerstate.secondary_verified_key.is_some() - || peerstate.secondary_verified_key_fingerprint.is_some() - || peerstate.secondary_verifier.is_some() - { - peerstate.secondary_verified_key = None; - peerstate.secondary_verified_key_fingerprint = None; - peerstate.secondary_verifier = None; - peerstate.save_to_db(&context.sql).await?; - } - - // No need to notify about secondary key removal. - Ok(None) - } else if signed_with_secondary_verified_key { - peerstate.verified_key = peerstate.secondary_verified_key.take(); - peerstate.verified_key_fingerprint = peerstate.secondary_verified_key_fingerprint.take(); - peerstate.verifier = peerstate.secondary_verifier.take(); - peerstate.fingerprint_changed = true; - peerstate.save_to_db(&context.sql).await?; - - // Primary verified key changed. - Ok(None) - } else { - Ok(None) - } -} - /// Checks whether the message is allowed to appear in a protected chat. /// /// This means that it is encrypted and signed with a verified key. -fn has_verified_encryption( +async fn has_verified_encryption( + context: &Context, mimeparser: &MimeMessage, from_id: ContactId, ) -> Result { @@ -3078,107 +3456,50 @@ fn has_verified_encryption( 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 Some(peerstate) = &mimeparser.peerstate else { - return Ok(NotVerified( - "No peerstate, the contact isn't verified".to_string(), - )); - }; - - let signed_with_verified_key = peerstate - .verified_key_fingerprint - .as_ref() - .filter(|fp| mimeparser.signatures.contains(fp)) - .is_some(); - - if !signed_with_verified_key { - return Ok(NotVerified( - "The message was sent with non-verified encryption".to_string(), - )); - } + if from_id == ContactId::SELF { + return Ok(Verified); } - Ok(Verified) + let from_contact = Contact::get_by_id(context, from_id).await?; + + let Some(fingerprint) = from_contact.fingerprint() else { + return Ok(NotVerified( + "The message was sent without encryption".to_string(), + )); + }; + + if from_contact.get_verifier_id(context).await?.is_none() { + return Ok(NotVerified( + "The message was sent by non-verified contact".to_string(), + )); + } + + let signed_with_verified_key = mimeparser.signatures.contains(&fingerprint); + if signed_with_verified_key { + Ok(Verified) + } else { + Ok(NotVerified( + "The message was sent with non-verified encryption".to_string(), + )) + } } async fn mark_recipients_as_verified( context: &Context, from_id: ContactId, - to_ids: &[ContactId], + to_ids: &[Option], mimeparser: &MimeMessage, ) -> Result<()> { if mimeparser.get_header(HeaderDef::ChatVerified).is_none() { return Ok(()); } - let contact = Contact::get_by_id(context, from_id).await?; - for &id in to_ids { - if id == ContactId::SELF { + for to_id in to_ids.iter().filter_map(|&x| x) { + if to_id == ContactId::SELF { continue; } - let Some((to_addr, is_verified)) = context - .sql - .query_row_optional( - "SELECT c.addr, LENGTH(ps.verified_key_fingerprint) FROM contacts c - LEFT JOIN acpeerstates ps ON c.addr=ps.addr WHERE c.id=?", - (id,), - |row| { - let to_addr: String = row.get(0)?; - let is_verified: i32 = row.get(1).unwrap_or(0); - Ok((to_addr, is_verified != 0)) - }, - ) - .await? - else { - continue; - }; - // mark gossiped keys (if any) as verified - if let Some(gossiped_key) = mimeparser.gossiped_keys.get(&to_addr.to_lowercase()) { - if let Some(mut peerstate) = Peerstate::from_addr(context, &to_addr).await? { - // If we're here, we know the gossip key is verified. - // - // Use the gossip-key as verified-key if there is no verified-key. - // - // Store gossip key as secondary verified key if there is a verified key and - // gossiped key is different. - // - // See - // and for discussion. - let verifier_addr = contact.get_addr().to_owned(); - if !is_verified { - info!(context, "{verifier_addr} has verified {to_addr}."); - if let Some(fp) = peerstate.gossip_key_fingerprint.clone() { - peerstate.set_verified(gossiped_key.clone(), fp, verifier_addr)?; - peerstate.backward_verified_key_id = - Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0); - peerstate.save_to_db(&context.sql).await?; - - let (to_contact_id, _) = Contact::add_or_lookup( - context, - "", - &ContactAddress::new(&to_addr)?, - Origin::Hidden, - ) - .await?; - ChatId::set_protection_for_contact( - context, - to_contact_id, - mimeparser.timestamp_sent, - ) - .await?; - } - } else { - // The contact already has a verified key. - // Store gossiped key as the secondary verified key. - peerstate.set_secondary_verified_key(gossiped_key.clone(), verifier_addr); - peerstate.save_to_db(&context.sql).await?; - } - } - } + mark_contact_id_as_verified(context, to_id, from_id).await?; + ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?; } Ok(()) @@ -3237,11 +3558,12 @@ async fn add_or_lookup_contacts_by_address_list( context: &Context, address_list: &[SingleInfo], origin: Origin, -) -> Result> { +) -> Result>> { let mut contact_ids = Vec::new(); for info in address_list { let addr = &info.addr; if !may_be_valid_addr(addr) { + contact_ids.push(None); continue; } let display_name = info.display_name.as_deref(); @@ -3249,14 +3571,215 @@ async fn add_or_lookup_contacts_by_address_list( let (contact_id, _) = Contact::add_or_lookup(context, display_name.unwrap_or_default(), &addr, origin) .await?; - contact_ids.push(contact_id); + contact_ids.push(Some(contact_id)); } else { warn!(context, "Contact with address {:?} cannot exist.", addr); + contact_ids.push(None); } } Ok(contact_ids) } +/// Looks up contact IDs from the database given the list of recipients. +async fn add_or_lookup_key_contacts_by_address_list( + context: &Context, + address_list: &[SingleInfo], + gossiped_keys: &HashMap, + fingerprints: &[Fingerprint], + origin: Origin, +) -> Result>> { + let mut contact_ids = Vec::new(); + let mut fingerprint_iter = fingerprints.iter(); + for info in address_list { + let addr = &info.addr; + if !may_be_valid_addr(addr) { + contact_ids.push(None); + continue; + } + let fingerprint: String = if let Some(fp) = fingerprint_iter.next() { + // Iterator has not ran out of fingerprints yet. + fp.hex() + } else if let Some(key) = gossiped_keys.get(addr) { + key.dc_fingerprint().hex() + } else { + contact_ids.push(None); + continue; + }; + let display_name = info.display_name.as_deref(); + if let Ok(addr) = ContactAddress::new(addr) { + let (contact_id, _) = Contact::add_or_lookup_ex( + context, + display_name.unwrap_or_default(), + &addr, + &fingerprint, + origin, + ) + .await?; + contact_ids.push(Some(contact_id)); + } else { + warn!(context, "Contact with address {:?} cannot exist.", addr); + contact_ids.push(None); + } + } + + debug_assert_eq!(contact_ids.len(), address_list.len()); + Ok(contact_ids) +} + +/// Looks up a key-contact by email address. +/// +/// If provided, `chat_id` must be an encrypted chat ID that has key-contacts inside. +/// Otherwise the function searches in all contacts, returning the recently seen one. +async fn lookup_key_contact_by_address( + context: &Context, + addr: &str, + chat_id: Option, +) -> Result> { + if context.is_self_addr(addr).await? { + let is_self_in_chat = context + .sql + .exists( + "SELECT COUNT(*) FROM chats_contacts WHERE chat_id=? AND contact_id=1", + (chat_id,), + ) + .await?; + if is_self_in_chat { + return Ok(Some(ContactId::SELF)); + } + } + let contact_id: Option = match chat_id { + Some(chat_id) => { + context + .sql + .query_row_optional( + "SELECT id FROM contacts + WHERE contacts.addr=? + AND EXISTS (SELECT 1 FROM chats_contacts + WHERE contact_id=contacts.id + AND chat_id=?) + AND fingerprint<>'' -- Should always be true + ", + (addr, chat_id), + |row| { + let contact_id: ContactId = row.get(0)?; + Ok(contact_id) + }, + ) + .await? + } + None => { + context + .sql + .query_row_optional( + "SELECT id FROM contacts + WHERE contacts.addr=?1 + AND fingerprint<>'' + ORDER BY last_seen DESC, id DESC + ", + (addr,), + |row| { + let contact_id: ContactId = row.get(0)?; + Ok(contact_id) + }, + ) + .await? + } + }; + Ok(contact_id) +} + +async fn lookup_key_contact_by_fingerprint( + context: &Context, + fingerprint: &str, +) -> Result> { + debug_assert!(!fingerprint.is_empty()); + if fingerprint.is_empty() { + // Avoid accidentally looking up a non-key-contact. + return Ok(None); + } + if let Some(contact_id) = context + .sql + .query_row_optional( + "SELECT id FROM contacts + WHERE fingerprint=? AND fingerprint!=''", + (fingerprint,), + |row| { + let contact_id: ContactId = row.get(0)?; + Ok(contact_id) + }, + ) + .await? + { + Ok(Some(contact_id)) + } else if let Some(self_fp) = self_fingerprint_opt(context).await? { + if self_fp == fingerprint { + Ok(Some(ContactId::SELF)) + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +/// Looks up key-contacts by email addresses. +/// +/// `fingerprints` may be empty. +/// This is used as a fallback when email addresses are available, +/// but not the fingerprints, e.g. when core 1.157.3 +/// client sends the `To` and `Chat-Group-Past-Members` header +/// but not the corresponding fingerprint list. +/// +/// Lookup is restricted to the chat ID. +/// +/// If contact cannot be found, `None` is returned. +/// This ensures that the length of the result vector +/// is the same as the number of addresses in the header +/// and it is possible to find corresponding +/// `Chat-Group-Member-Timestamps` items. +async fn lookup_key_contacts_by_address_list( + context: &Context, + address_list: &[SingleInfo], + fingerprints: &[Fingerprint], + chat_id: Option, +) -> Result>> { + let mut contact_ids = Vec::new(); + let mut fingerprint_iter = fingerprints.iter(); + for info in address_list { + let addr = &info.addr; + if !may_be_valid_addr(addr) { + contact_ids.push(None); + continue; + } + + if let Some(fp) = fingerprint_iter.next() { + // Iterator has not ran out of fingerprints yet. + let display_name = info.display_name.as_deref(); + let fingerprint: String = fp.hex(); + + if let Ok(addr) = ContactAddress::new(addr) { + let (contact_id, _) = Contact::add_or_lookup_ex( + context, + display_name.unwrap_or_default(), + &addr, + &fingerprint, + Origin::Hidden, + ) + .await?; + contact_ids.push(Some(contact_id)); + } else { + warn!(context, "Contact with address {:?} cannot exist.", addr); + contact_ids.push(None); + } + } else { + let contact_id = lookup_key_contact_by_address(context, addr, chat_id).await?; + contact_ids.push(contact_id); + } + } + debug_assert_eq!(address_list.len(), contact_ids.len()); + Ok(contact_ids) +} + #[cfg(test)] mod receive_imf_tests; diff --git a/src/receive_imf/receive_imf_tests.rs b/src/receive_imf/receive_imf_tests.rs index 5b1ce99e6..1555edf33 100644 --- a/src/receive_imf/receive_imf_tests.rs +++ b/src/receive_imf/receive_imf_tests.rs @@ -10,13 +10,14 @@ use crate::chat::{ ChatVisibility, }; use crate::chatlist::Chatlist; -use crate::constants::{DC_GCL_FOR_FORWARDING, DC_GCL_NO_SPECIALS}; +use crate::constants::DC_GCL_FOR_FORWARDING; use crate::contact; use crate::download::MIN_DOWNLOAD_LIMIT; use crate::imap::prefetch_should_download; use crate::imex::{imex, ImexMode}; use crate::securejoin::get_securejoin_qr; -use crate::test_utils::{get_chat_msg, mark_as_verified, TestContext, TestContextManager}; +use crate::test_utils::mark_as_verified; +use crate::test_utils::{get_chat_msg, TestContext, TestContextManager}; use crate::tools::{time, SystemTime}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -270,120 +271,6 @@ async fn test_adhoc_groups_merge() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_read_receipt_and_unarchive() -> Result<()> { - // create alice's account - let t = TestContext::new_alice().await; - - let bob_id = Contact::create(&t, "bob", "bob@example.com").await?; - let one2one_id = ChatId::create_for_contact(&t, bob_id).await?; - one2one_id - .set_visibility(&t, ChatVisibility::Archived) - .await - .unwrap(); - let one2one = Chat::load_from_db(&t, one2one_id).await?; - assert!(one2one.get_visibility() == ChatVisibility::Archived); - - // create a group with bob, archive group - let group_id = chat::create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; - chat::add_contact_to_chat(&t, group_id, bob_id).await?; - assert_eq!(chat::get_chat_msgs(&t, group_id).await.unwrap().len(), 0); - group_id - .set_visibility(&t, ChatVisibility::Archived) - .await?; - let group = Chat::load_from_db(&t, group_id).await?; - assert!(group.get_visibility() == ChatVisibility::Archived); - - // everything archived, chatlist should be empty - assert_eq!( - Chatlist::try_load(&t, DC_GCL_NO_SPECIALS, None, None) - .await? - .len(), - 0 - ); - - // send a message to group with bob - receive_imf( - &t, - format!( - "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: foo\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: {}\n\ - Chat-Group-Name: foo\n\ - Chat-Disposition-Notification-To: alice@example.org\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello\n", - group.grpid, group.grpid - ) - .as_bytes(), - false, - ) - .await?; - let msg = get_chat_msg(&t, group_id, 0, 1).await; - assert_eq!(msg.is_dc_message, MessengerMessage::Yes); - assert_eq!(msg.text, "hello"); - assert_eq!(msg.state, MessageState::OutDelivered); - let group = Chat::load_from_db(&t, group_id).await?; - assert!(group.get_visibility() == ChatVisibility::Normal); - - // bob sends a read receipt to the group - receive_imf( - &t, - format!( - "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: bob@example.com\n\ - To: alice@example.org\n\ - Subject: message opened\n\ - Date: Sun, 22 Mar 2020 23:37:57 +0000\n\ - Chat-Version: 1.0\n\ - Message-ID: \n\ - Content-Type: multipart/report; report-type=disposition-notification; boundary=\"SNIPP\"\n\ - \n\ - \n\ - --SNIPP\n\ - Content-Type: text/plain; charset=utf-8\n\ - \n\ - Read receipts do not guarantee sth. was read.\n\ - \n\ - \n\ - --SNIPP\n\ - Content-Type: message/disposition-notification\n\ - \n\ - Reporting-UA: Delta Chat 1.28.0\n\ - Original-Recipient: rfc822;bob@example.com\n\ - Final-Recipient: rfc822;bob@example.com\n\ - Original-Message-ID: \n\ - Disposition: manual-action/MDN-sent-automatically; displayed\n\ - \n\ - \n\ - --SNIPP--", - group.grpid - ) - .as_bytes(), - false, - ) - .await?; - assert_eq!(chat::get_chat_msgs(&t, group_id).await?.len(), 1); - let msg = message::Message::load_from_db(&t, msg.id).await?; - assert_eq!(msg.state, MessageState::OutMdnRcvd); - - // check, the read-receipt has not unarchived the one2one - assert_eq!( - Chatlist::try_load(&t, DC_GCL_NO_SPECIALS, None, None) - .await? - .len(), - 1 - ); - let one2one = Chat::load_from_db(&t, one2one_id).await?; - assert!(one2one.get_visibility() == ChatVisibility::Archived); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_mdn_and_alias() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -1529,7 +1416,7 @@ async fn test_mailing_list_with_mimepart_footer_signed() { } /// Test that the changes from apply_mailinglist_changes() are also applied -/// if the message is assigned to the chat by In-Reply-To +/// if the message is a reply. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_apply_mailinglist_changes_assigned_by_reply() { let t = TestContext::new_alice().await; @@ -1548,10 +1435,6 @@ async fn test_apply_mailinglist_changes_assigned_by_reply() { t.get_last_msg().await.in_reply_to.unwrap(), "3333@example.org" ); - // `Assigning message to Chat#... as it's a reply to 3333@example.org` - t.evtracker - .get_info_contains("as it's a reply to 3333@example.org") - .await; let chat = Chat::load_from_db(&t, chat_id).await.unwrap(); assert!(!chat.can_send(&t).await.unwrap()); @@ -1782,110 +1665,6 @@ async fn test_in_reply_to() { assert!(!msg.chat_id.is_special()); } -/// Test that classical MUA messages are assigned to group chats -/// based on the `In-Reply-To` header for two-member groups. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_in_reply_to_two_member_group() { - let t = TestContext::new().await; - t.configure_addr("bob@example.com").await; - - // Receive message from Alice about group "foo". - receive_imf( - &t, - b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: foo\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: foobarbaz12\n\ - Chat-Group-Name: foo\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello foo\n", - false, - ) - .await - .unwrap(); - - // Receive a classic MUA reply from Alice. - // It is assigned to the group chat. - receive_imf( - &t, - b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: Re: foo\n\ - Message-ID: \n\ - In-Reply-To: \n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - classic reply\n", - false, - ) - .await - .unwrap(); - - // Ensure message is assigned to group chat. - let msg = t.get_last_msg().await; - let chat = Chat::load_from_db(&t, msg.chat_id).await.unwrap(); - assert_eq!(chat.typ, Chattype::Group); - assert_eq!(msg.get_text(), "classic reply"); - - // Receive a Delta Chat reply from Alice. - // It is assigned to group chat, because it has a group ID. - receive_imf( - &t, - b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: Re: foo\n\ - Message-ID: \n\ - In-Reply-To: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: foobarbaz12\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - chat reply\n", - false, - ) - .await - .unwrap(); - - // Ensure message is assigned to group chat. - let msg = t.get_last_msg().await; - let chat = Chat::load_from_db(&t, msg.chat_id).await.unwrap(); - assert_eq!(chat.typ, Chattype::Group); - assert_eq!(msg.get_text(), "chat reply"); - - // Receive a private Delta Chat reply from Alice. - // It is assigned to 1:1 chat, because it has no group ID, - // which means it was created using "reply privately" feature. - // Normally it contains a quote, but it should not matter. - receive_imf( - &t, - b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: Re: foo\n\ - Message-ID: \n\ - In-Reply-To: \n\ - Chat-Version: 1.0\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - private reply\n", - false, - ) - .await - .unwrap(); - - // Ensure message is assigned to a 1:1 chat. - let msg = t.get_last_msg().await; - let chat = Chat::load_from_db(&t, msg.chat_id).await.unwrap(); - assert_eq!(chat.typ, Chattype::Single); - assert_eq!(msg.get_text(), "private reply"); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_save_mime_headers_off() -> anyhow::Result<()> { let alice = TestContext::new_alice().await; @@ -2053,17 +1832,21 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo "bob@example.net" ); // Bob is not part of the group, so override-sender-name should be set - // Check that Claire also gets the message in the same chat. + // Claire gets the reply as ad hoc group. let request = claire.get_last_msg().await; receive_imf(&claire, reply.as_bytes(), false).await.unwrap(); let answer = claire.get_last_msg().await; assert_eq!(answer.get_subject(), "Re: i have a question"); assert!(answer.get_text().contains("the version is 1.0")); - assert_eq!(answer.chat_id, request.chat_id); - assert_eq!( - answer.get_override_sender_name().unwrap(), - "bob@example.net" - ); + if group_request { + assert_eq!(answer.chat_id, request.chat_id); + assert_eq!( + answer.get_override_sender_name().unwrap(), + "bob@example.net" + ); + } else { + assert_ne!(answer.chat_id, request.chat_id); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2561,7 +2344,7 @@ async fn test_chat_assignment_private_chat_reply() { Subject: =?utf-8?q?single_reply-to?= {} Date: Fri, 28 May 2021 10:15:05 +0000 -To: Bob {} +To: Bob , Charlie {} From: Alice Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no Content-Transfer-Encoding: quoted-printable @@ -2752,8 +2535,10 @@ Reply to all"#, /// headers. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chat_assignment_adhoc() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let fiona = tcm.fiona().await; let first_thread_mime = br#"Subject: First thread Message-ID: first@example.org @@ -2783,8 +2568,8 @@ Second thread."#; let bob_second_msg = bob.get_last_msg().await; // Messages go to separate chats both for Alice and Bob. - assert!(alice_first_msg.chat_id != alice_second_msg.chat_id); - assert!(bob_first_msg.chat_id != bob_second_msg.chat_id); + assert_ne!(alice_first_msg.chat_id, alice_second_msg.chat_id); + assert_ne!(bob_first_msg.chat_id, bob_second_msg.chat_id); // Alice replies to both chats. Bob receives two messages and assigns them to corresponding // chats. @@ -2803,8 +2588,7 @@ Second thread."#; assert_eq!(bob_second_reply.chat_id, bob_second_msg.chat_id); // Alice adds Fiona to both ad hoc groups. - let fiona = TestContext::new_fiona().await; - let alice_fiona_contact = alice.add_or_lookup_contact(&fiona).await; + let alice_fiona_contact = alice.add_or_lookup_address_contact(&fiona).await; let alice_fiona_contact_id = alice_fiona_contact.id; chat::add_contact_to_chat(&alice, alice_first_msg.chat_id, alice_fiona_contact_id).await?; @@ -2817,104 +2601,7 @@ Second thread."#; // Fiona was added to two separate chats and should see two separate chats, even though they // don't have different group IDs to distinguish them. - assert!(fiona_first_invite.chat_id != fiona_second_invite.chat_id); - - Ok(()) -} - -/// Test that `Chat-Group-ID` is preferred over `In-Reply-To` and `References`. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_chat_assignment_chat_group_id_preference() -> Result<()> { - let t = &TestContext::new_alice().await; - - receive_imf( - t, - br#"Subject: Hello -Chat-Group-ID: eJ_llQIXf0K -Chat-Group-Name: Group name -Chat-Version: 1.0 -Message-ID: -References: -Date: Fri, 28 May 2021 10:15:05 +0000 -From: Alice -To: Bob , -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no -Content-Transfer-Encoding: quoted-printable - -Hello, I've just created a group for us."#, - false, - ) - .await?; - let group_msg = t.get_last_msg().await; - - receive_imf( - t, - br#"Subject: Hello -Chat-Version: 1.0 -Message-ID: -References: -Date: Fri, 28 May 2021 10:15:05 +0000 -From: Bob -To: Alice -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no -Content-Transfer-Encoding: quoted-printable - -Hello from Bob in 1:1 chat."#, - false, - ) - .await?; - - // References and In-Reply-To point to a message - // already assigned to 1:1 chat, but Chat-Group-ID is - // a stronger signal to assign message to a group. - receive_imf( - t, - br#"Subject: Hello -Chat-Group-ID: eJ_llQIXf0K -Chat-Group-Name: Group name -Chat-Version: 1.0 -Message-ID: -In-Reply-To: -References: -Date: Fri, 28 May 2021 10:15:05 +0000 -From: Bob -To: Alice , -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no -Content-Transfer-Encoding: quoted-printable - -Hello from Bob in a group."#, - false, - ) - .await?; - - let msg = t.get_last_msg().await; - assert_eq!(msg.text, "Hello from Bob in a group."); - assert_eq!(msg.chat_id, group_msg.chat_id); - - // Test outgoing message as well. - receive_imf( - t, - br#"Subject: Hello -Chat-Group-ID: eJ_llQIXf0K -Chat-Group-Name: Group name -Chat-Version: 1.0 -Message-ID: -In-Reply-To: -References: -Date: Fri, 28 May 2021 10:15:05 +0000 -From: Alice -To: Bob , -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no -Content-Transfer-Encoding: quoted-printable - -Hello from Alice in a group."#, - false, - ) - .await?; - - let msg_outgoing = t.get_last_msg().await; - assert_eq!(msg_outgoing.text, "Hello from Alice in a group."); - assert_eq!(msg_outgoing.chat_id, group_msg.chat_id); + assert_ne!(fiona_first_invite.chat_id, fiona_second_invite.chat_id); Ok(()) } @@ -3111,20 +2798,16 @@ Message with references."#; /// Test a message with RFC 1847 encapsulation as created by Thunderbird. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_rfc1847_encapsulation() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; - // Alice sends an Autocrypt message to Bob so Bob gets Alice's key. - let chat_alice = alice.create_chat(&bob).await; - let first_msg = alice - .send_text(chat_alice.id, "Sending Alice key to Bob.") - .await; - bob.recv_msg(&first_msg).await; - message::delete_msgs(&bob, &[bob.get_last_msg().await.id]).await?; + // Bob gets Alice's key via vCard. + bob.add_or_lookup_contact_id(alice).await; // Alice sends a message to Bob using Thunderbird. let raw = include_bytes!("../../test-data/message/rfc1847_encapsulation.eml"); - receive_imf(&bob, raw, false).await?; + receive_imf(bob, raw, false).await?; let msg = bob.get_last_msg().await; assert!(msg.get_showpadlock()); @@ -3144,70 +2827,6 @@ async fn test_invalid_to_address() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_reply_from_different_addr() -> Result<()> { - let t = TestContext::new_alice().await; - - // Alice creates a 2-person-group with Bob - receive_imf( - &t, - br#"Subject: =?utf-8?q?Januar_13-19?= -Chat-Group-ID: qetqsutor7a -Chat-Group-Name: =?utf-8?q?Januar_13-19?= -MIME-Version: 1.0 -References: -Date: Mon, 20 Dec 2021 12:15:01 +0000 -Chat-Version: 1.0 -Message-ID: -To: -From: -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no - -Hi, I created a group"#, - false, - ) - .await?; - let msg_out = t.get_last_msg().await; - assert_eq!(msg_out.from_id, ContactId::SELF); - assert_eq!(msg_out.text, "Hi, I created a group"); - assert_eq!(msg_out.in_reply_to, None); - - // Bob replies from a different address - receive_imf( - &t, - b"Content-Type: text/plain; charset=utf-8 -Content-Transfer-Encoding: quoted-printable -From: -Mime-Version: 1.0 (1.0) -Subject: Re: Januar 13-19 -Date: Mon, 20 Dec 2021 13:54:55 +0100 -Message-Id: -References: -In-Reply-To: -To: holger - -Reply from different address -", - false, - ) - .await?; - let msg_in = t.get_last_msg().await; - assert_eq!(msg_in.to_id, ContactId::SELF); - assert_eq!(msg_in.text, "Reply from different address"); - assert_eq!( - msg_in.in_reply_to.unwrap(), - "Gr.qetqsutor7a.Aresxresy-4@deltachat.de" - ); - assert_eq!( - msg_in.param.get(Param::OverrideSenderDisplayname), - Some("bob-alias@example.com") - ); - - assert_eq!(msg_in.chat_id, msg_out.chat_id); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_weird_and_duplicated_filenames() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -3358,7 +2977,7 @@ async fn test_outgoing_private_reply_multidevice() -> Result<()> { // =============== Alice's second device receives the message =============== let received = alice2.get_last_msg().await; - // That's a regression test for https://github.com/deltachat/deltachat-core-rust/issues/2949: + // That's a regression test for https://github.com/chatmail/core/issues/2949: assert_eq!(received.chat_id, alice2.get_chat(&bob).await.id); let alice2_bob_contact = alice2.add_or_lookup_contact(&bob).await; @@ -3382,13 +3001,15 @@ async fn test_outgoing_private_reply_multidevice() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_auto_accept_for_bots() -> Result<()> { - let t = TestContext::new_alice().await; - t.set_config(Config::Bot, Some("1")).await.unwrap(); - receive_imf(&t, MSGRMSG, false).await?; - let msg = t.get_last_msg().await; - let chat = chat::Chat::load_from_db(&t, msg.chat_id).await?; + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + alice.set_config(Config::Bot, Some("1")).await.unwrap(); + let msg = tcm.send_recv(bob, alice, "Hello!").await; + let chat = chat::Chat::load_from_db(alice, msg.chat_id).await?; assert!(!chat.is_contact_request()); - assert!(Contact::get_all(&t, 0, None).await?.len() == 1); + + assert_eq!(Contact::get_all(alice, 0, None).await?.len(), 1); Ok(()) } @@ -3602,12 +3223,10 @@ async fn test_outgoing_undecryptable() -> Result<()> { let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml"); receive_imf(alice, raw, false).await?; - let bob_contact_id = Contact::lookup_id_by_addr(alice, "bob@example.net", Origin::OutgoingTo) - .await? - .unwrap(); - assert!(ChatId::lookup_by_contact(alice, bob_contact_id) - .await? - .is_none()); + // Undecryptable message does not even create a contact. + let bob_contact_id = + Contact::lookup_id_by_addr(alice, "bob@example.net", Origin::OutgoingTo).await?; + assert!(bob_contact_id.is_none()); let dev_chat_id = ChatId::lookup_by_contact(alice, ContactId::DEVICE) .await? @@ -3621,67 +3240,85 @@ async fn test_outgoing_undecryptable() -> Result<()> { let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed.eml"); receive_imf(alice, raw, false).await?; - assert!(ChatId::lookup_by_contact(alice, bob_contact_id) - .await? - .is_none()); + let bob_contact_id = + Contact::lookup_id_by_addr(alice, "bob@example.net", Origin::OutgoingTo).await?; + assert!(bob_contact_id.is_none()); // The device message mustn't be added too frequently. assert_eq!(alice.get_last_msg_in(dev_chat_id).await.id, dev_msg.id); Ok(()) } +/// Tests that a message from Thunderbird with an Autocrypt header is assigned to the key-contact. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_thunderbird_autocrypt() -> Result<()> { let t = TestContext::new_bob().await; let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml"); let received_msg = receive_imf(&t, raw, false).await?.unwrap(); - assert!(received_msg.from_is_signed); - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + + let message = Message::load_from_db(&t, msg_id).await?; + assert!(message.get_showpadlock()); + + let from_id = message.from_id; + + let from_contact = Contact::get_by_id(&t, from_id).await?; + assert!(from_contact.is_key_contact()); Ok(()) } +/// Tests reception of a message from Thunderbird with attached key. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_prefer_encrypt_mutual_if_encrypted() -> Result<()> { let t = TestContext::new_bob().await; + // The message has public key attached *and* Autocrypt header. + // + // Autocrypt header is used to check the signature. + // + // At the time of the writing (2025-04-30, introduction of key-contacts) + // signature checking does not work without the Autocrypt header. let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed_with_pubkey.eml"); - receive_imf(&t, raw, false).await?; - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); + let received_msg = receive_imf(&t, raw, false).await?.unwrap(); - receive_imf( - &t, - b"From: alice@example.org\n\ - To: bob@example.net\n\ - Subject: foo\n\ - Message-ID: \n\ - Date: Thu, 2 Nov 2023 02:20:28 -0300\n\ - \n\ - unencrypted\n", - false, - ) - .await?; - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Reset); + // Attached key does not appear as an attachment, + // there is only one part. + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + let message = Message::load_from_db(&t, msg_id).await?; + assert!(message.get_showpadlock()); + + let alice_id = message.from_id; + let alice_contact = Contact::get_by_id(&t, alice_id).await?; + assert!(alice_contact.is_key_contact()); + + // The message without the Autocrypt header + // cannot be assigned to the contact even if it + // is encrypted and signed. + // + // This could be fixed by looking up + // the key by the issuer fingerprint + // which is present in the detached signature, + // but this is not done yet. let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed.eml"); - receive_imf(&t, raw, false).await?; - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert!(peerstate.public_key.is_some()); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); + let received_msg = receive_imf(&t, raw, false).await?.unwrap(); + + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + + let message = Message::load_from_db(&t, msg_id).await?; + assert!(!message.get_showpadlock()); + + let alice_email_id = message.from_id; + assert_ne!(alice_email_id, alice_id); + let alice_address_contact = Contact::get_by_id(&t, alice_email_id).await?; + assert!(!alice_address_contact.is_key_contact()); Ok(()) } @@ -3691,8 +3328,10 @@ async fn test_forged_from_and_no_valid_signatures() -> Result<()> { let t = &TestContext::new_bob().await; let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed.eml"); let received_msg = receive_imf(t, raw, false).await?.unwrap(); - assert!(!received_msg.from_is_signed); - let msg = t.get_last_msg().await; + + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + let msg = Message::load_from_db(t, msg_id).await?; assert!(!msg.chat_id.is_trash()); assert!(!msg.get_showpadlock()); @@ -3709,8 +3348,7 @@ async fn test_wrong_from_name_and_no_valid_signatures() -> Result<()> { let t = &TestContext::new_bob().await; let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed.eml"); let raw = String::from_utf8(raw.to_vec())?.replace("From: Alice", "From: A"); - let received_msg = receive_imf(t, raw.as_bytes(), false).await?.unwrap(); - assert!(!received_msg.from_is_signed); + receive_imf(t, raw.as_bytes(), false).await?.unwrap(); let msg = t.get_last_msg().await; assert!(!msg.chat_id.is_trash()); assert!(!msg.get_showpadlock()); @@ -3721,21 +3359,34 @@ async fn test_wrong_from_name_and_no_valid_signatures() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_thunderbird_autocrypt_unencrypted() -> Result<()> { - let t = TestContext::new_bob().await; + let bob = &TestContext::new_bob().await; + // Thunderbird message with Autocrypt header and a signature, + // but not encrypted. let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt_unencrypted.eml"); - receive_imf(&t, raw, false).await?; - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); + let received_msg = receive_imf(bob, raw, false).await?.unwrap(); + + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + let msg = Message::load_from_db(bob, msg_id).await?; + assert!(!msg.get_showpadlock()); + + // The message should arrive as address-contact + let alice_id = msg.from_id; + let alice_contact = Contact::get_by_id(bob, alice_id).await?; + assert!(!alice_contact.is_key_contact()); let raw = include_bytes!("../../test-data/message/thunderbird_signed_unencrypted.eml"); - receive_imf(&t, raw, false).await?; - let peerstate = Peerstate::from_addr(&t, "alice@example.org") - .await? - .unwrap(); - assert_eq!(peerstate.prefer_encrypt, EncryptPreference::Mutual); + let received_msg = receive_imf(bob, raw, false).await?.unwrap(); + + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + let msg = Message::load_from_db(bob, msg_id).await?; + assert!(!msg.get_showpadlock()); + + let alice_id = msg.from_id; + let alice_contact = Contact::get_by_id(bob, alice_id).await?; + assert!(!alice_contact.is_key_contact()); Ok(()) } @@ -3750,8 +3401,7 @@ async fn test_thunderbird_unsigned() -> Result<()> { // Alice receives an unsigned message from Bob. let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_unsigned.eml"); - let received_msg = receive_imf(&alice, raw, false).await?.unwrap(); - assert!(!received_msg.from_is_signed); + receive_imf(&alice, raw, false).await?.unwrap(); let msg = alice.get_last_msg().await; assert!(!msg.get_showpadlock()); @@ -3852,6 +3502,7 @@ async fn test_big_forwarded_with_big_attachment() -> Result<()> { Ok(()) } +/// Tests that MUA user can add members to ad-hoc group. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_mua_user_adds_member() -> Result<()> { let t = TestContext::new_alice().await; @@ -3859,42 +3510,41 @@ async fn test_mua_user_adds_member() -> Result<()> { receive_imf( &t, b"From: alice@example.org\n\ - To: bob@example.com\n\ - Subject: foo\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: gggroupiddd\n\ - Chat-Group-Name: foo\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello\n", + To: bob@example.com, charlie@example.net\n\ + Subject: foo\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + Chat-Group-ID: gggroupiddd\n\ + Chat-Group-Name: foo\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", false, ) .await? .unwrap(); - receive_imf( + let msg = receive_imf( &t, b"From: bob@example.com\n\ - To: alice@example.org, fiona@example.net\n\ - Subject: foo\n\ - Message-ID: \n\ - In-Reply-To: Gr.gggroupiddd.12345678901@example.com\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello\n", + To: alice@example.org, charlie@example.net, fiona@example.net\n\ + Subject: foo\n\ + Message-ID: \n\ + In-Reply-To: Gr.gggroupiddd.12345678901@example.com\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", false, ) .await? .unwrap(); - let (chat_id, _, _) = chat::get_chat_id_by_grpid(&t, "gggroupiddd") - .await? - .unwrap(); + let chat_id = msg.chat_id; let mut actual_chat_contacts = chat::get_chat_contacts(&t, chat_id).await?; actual_chat_contacts.sort(); let mut expected_chat_contacts = vec![ Contact::create(&t, "", "bob@example.com").await?, + Contact::create(&t, "", "charlie@example.net").await?, Contact::create(&t, "", "fiona@example.net").await?, ContactId::SELF, ]; @@ -4663,12 +4313,9 @@ async fn test_create_group_with_big_msg() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - let ba_contact = Contact::create( - &bob, - "alice", - &alice.get_config(Config::Addr).await?.unwrap(), - ) - .await?; + let ba_contact = bob.add_or_lookup_contact_id(&alice).await; + let ab_chat_id = alice.create_chat(&bob).await.id; + let file_bytes = include_bytes!("../../test-data/image/screenshot.png"); let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?; @@ -4676,26 +4323,22 @@ async fn test_create_group_with_big_msg() -> Result<()> { let mut msg = Message::new(Viewtype::Image); msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?; let sent_msg = bob.send_msg(bob_grp_id, &mut msg).await; - assert!(!msg.get_showpadlock()); + assert!(msg.get_showpadlock()); alice.set_config(Config::DownloadLimit, Some("1")).await?; assert_eq!(alice.download_limit().await?, Some(MIN_DOWNLOAD_LIMIT)); let msg = alice.recv_msg(&sent_msg).await; assert_eq!(msg.download_state, DownloadState::Available); - let alice_grp = Chat::load_from_db(&alice, msg.chat_id).await?; - assert_eq!(alice_grp.typ, Chattype::Group); - assert_eq!(alice_grp.name, "Group"); - assert_eq!( - chat::get_chat_contacts(&alice, alice_grp.id).await?.len(), - 2 - ); + let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?; + // Incomplete message is assigned to 1:1 chat. + assert_eq!(alice_chat.typ, Chattype::Single); alice.set_config(Config::DownloadLimit, None).await?; let msg = alice.recv_msg(&sent_msg).await; assert_eq!(msg.download_state, DownloadState::Done); assert_eq!(msg.state, MessageState::InFresh); assert_eq!(msg.viewtype, Viewtype::Image); - assert_eq!(msg.chat_id, alice_grp.id); + assert_ne!(msg.chat_id, alice_chat.id); let alice_grp = Chat::load_from_db(&alice, msg.chat_id).await?; assert_eq!(alice_grp.typ, Chattype::Group); assert_eq!(alice_grp.name, "Group"); @@ -4704,7 +4347,6 @@ async fn test_create_group_with_big_msg() -> Result<()> { 2 ); - let ab_chat_id = tcm.send_recv_accept(&alice, &bob, "hi").await.chat_id; // Now Bob can send encrypted messages to Alice. let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group1").await?; @@ -4735,7 +4377,8 @@ async fn test_create_group_with_big_msg() -> Result<()> { ); // The big message must go away from the 1:1 chat. - assert_eq!(alice.get_last_msg_in(ab_chat_id).await.text, "hi"); + let msgs = chat::get_chat_msgs(&alice, ab_chat_id).await?; + assert!(msgs.is_empty()); Ok(()) } @@ -4821,52 +4464,19 @@ Chat-Group-Member-Added: charlie@example.com", Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_leave_protected_group_missing_member_key() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - mark_as_verified(alice, bob).await; - let alice_bob_id = alice.add_or_lookup_contact(bob).await.id; - let group_id = create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; - add_contact_to_chat(alice, group_id, alice_bob_id).await?; - alice.send_text(group_id, "Hello!").await; - alice - .sql - .execute( - "UPDATE acpeerstates SET addr=? WHERE addr=?", - ("b@b", "bob@example.net"), - ) - .await?; - - // We fail to send the message. - assert!(remove_contact_from_chat(alice, group_id, ContactId::SELF) - .await - .is_err()); - - // The contact is already removed anyway. - assert!(!is_contact_in_chat(alice, group_id, ContactId::SELF).await?); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_protected_group_add_remove_member_missing_key() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let bob_addr = bob.get_config(Config::Addr).await?.unwrap(); mark_as_verified(alice, bob).await; let group_id = create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; let alice_bob_id = alice.add_or_lookup_contact(bob).await.id; add_contact_to_chat(alice, group_id, alice_bob_id).await?; alice.send_text(group_id, "Hello!").await; - alice - .sql - .execute("DELETE FROM acpeerstates WHERE addr=?", (&bob_addr,)) - .await?; + alice.sql.execute("DELETE FROM public_keys", ()).await?; let fiona = &tcm.fiona().await; - let fiona_addr = fiona.get_config(Config::Addr).await?.unwrap(); mark_as_verified(alice, fiona).await; let alice_fiona_id = alice.add_or_lookup_contact(fiona).await.id; add_contact_to_chat(alice, group_id, alice_fiona_id).await?; @@ -4878,7 +4488,7 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> { assert!(msg.is_info()); assert_eq!( msg.get_text(), - stock_str::msg_add_member_local(alice, &fiona_addr, ContactId::SELF).await + stock_str::msg_add_member_local(alice, alice_fiona_id, ContactId::SELF).await ); remove_contact_from_chat(alice, group_id, alice_bob_id).await?; @@ -4887,42 +4497,11 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> { assert!(msg.is_info()); assert_eq!( msg.get_text(), - stock_str::msg_del_member_local(alice, &bob_addr, ContactId::SELF,).await + stock_str::msg_del_member_local(alice, alice_bob_id, ContactId::SELF).await ); Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_protected_group_reply_from_mua() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - let fiona = &tcm.fiona().await; - mark_as_verified(alice, bob).await; - mark_as_verified(alice, fiona).await; - mark_as_verified(bob, alice).await; - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob, fiona]) - .await; - let sent = alice.send_text(alice_chat_id, "Hello!").await; - let bob_msg = bob.recv_msg(&sent).await; - bob_msg.chat_id.accept(bob).await?; - // This is hacky, but i don't know other simple way to simulate a MUA reply. It works because - // the message is correctly assigned to the chat by `References:`. - bob.sql - .execute( - "UPDATE chats SET protected=0, grpid='' WHERE id=?", - (bob_msg.chat_id,), - ) - .await?; - let sent = bob - .send_text(bob_msg.chat_id, "/me replying from MUA") - .await; - let alice_msg = alice.recv_msg(&sent).await; - assert_eq!(alice_msg.chat_id, alice_chat_id); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_older_message_from_2nd_device() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -5020,6 +4599,7 @@ async fn test_no_op_member_added_is_trash() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; + let fiona = &tcm.fiona().await; let alice_chat_id = alice .create_group_with_members(ProtectionStatus::Unprotected, "foos", &[bob]) .await; @@ -5029,11 +4609,11 @@ async fn test_no_op_member_added_is_trash() -> Result<()> { let bob_chat_id = bob.get_last_msg().await.chat_id; bob_chat_id.accept(bob).await?; - let fiona_id = Contact::create(alice, "", "fiona@example.net").await?; + let fiona_id = alice.add_or_lookup_contact_id(fiona).await; add_contact_to_chat(alice, alice_chat_id, fiona_id).await?; let msg = alice.pop_sent_msg().await; - let fiona_id = Contact::create(bob, "", "fiona@example.net").await?; + let fiona_id = bob.add_or_lookup_contact_id(fiona).await; add_contact_to_chat(bob, bob_chat_id, fiona_id).await?; bob.recv_msg_trash(&msg).await; let contacts = get_chat_contacts(bob, bob_chat_id).await?; @@ -5291,8 +4871,12 @@ async fn test_make_n_send_vcard() -> Result<()> { Ok(()) } +/// Tests that group is not created if the message +/// has no recipients even if it has unencrypted Chat-Group-ID. +/// +/// Chat-Group-ID in unencrypted messages should be ignored. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_group_no_recipients() -> Result<()> { +async fn test_unencrypted_group_id_no_recipients() -> Result<()> { let t = &TestContext::new_alice().await; let raw = "From: alice@example.org Subject: Group @@ -5307,7 +4891,7 @@ Hello!" let received = receive_imf(t, raw, false).await?.unwrap(); let msg = Message::load_from_db(t, *received.msg_ids.last().unwrap()).await?; let chat = Chat::load_from_db(t, msg.chat_id).await?; - assert_eq!(chat.typ, Chattype::Group); + assert_eq!(chat.typ, Chattype::Single); // Check that the weird group name is sanitzied correctly: let mail = mailparse::parse_mail(raw).unwrap(); @@ -5318,38 +4902,30 @@ Hello!" .get_value_raw(), "Group\n name\u{202B}".as_bytes() ); - assert_eq!(chat.name, "Group name"); + assert_eq!(chat.name, "Saved messages"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_group_name_with_newline() -> Result<()> { - let t = &TestContext::new_alice().await; - let raw = "From: alice@example.org -Subject: Group -Chat-Version: 1.0 -Chat-Group-Name: =?utf-8?q?Delta=0D=0AChat?= -Chat-Group-ID: GePFDkwEj2K -Message-ID: + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; -Hello!" - .as_bytes(); - let received = receive_imf(t, raw, false).await?.unwrap(); - let msg = Message::load_from_db(t, *received.msg_ids.last().unwrap()).await?; - let chat = Chat::load_from_db(t, msg.chat_id).await?; + let chat_id = create_group_chat( + alice, + ProtectionStatus::Unprotected, + "Group\r\nwith\nnewlines", + ) + .await?; + add_contact_to_chat(alice, chat_id, alice.add_or_lookup_contact_id(bob).await).await?; + send_text_msg(alice, chat_id, "populate".to_string()).await?; + let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id; + + let chat = Chat::load_from_db(bob, bob_chat_id).await?; assert_eq!(chat.typ, Chattype::Group); - - // Check that the weird group name is sanitzied correctly: - let mail = mailparse::parse_mail(raw).unwrap(); - assert_eq!( - mail.headers - .get_header(HeaderDef::ChatGroupName) - .unwrap() - .get_value(), - "Delta\r\nChat" - ); - assert_eq!(chat.name, "Delta Chat"); + assert_eq!(chat.name, "Group with newlines"); Ok(()) } @@ -5413,133 +4989,6 @@ async fn test_rename_chat_after_creating_invite() -> Result<()> { Ok(()) } -/// Tests that creating a group -/// is preferred over assigning message to existing -/// chat based on `In-Reply-To` and `References`. -/// -/// Referenced message itself may be incorrectly assigned, -/// but `Chat-Group-ID` uniquely identifies the chat. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_prefer_chat_group_id_over_references() -> Result<()> { - let t = &TestContext::new_alice().await; - - // Alice receives 1:1 message from Bob. - let raw = b"From: bob@example.net\n\ - To: alice@example.org\n\ - Subject: Hi\n\ - Message-ID: \n\ - \n\ - Hello!"; - receive_imf(t, raw, false).await?.unwrap(); - - // Alice receives a group message from Bob. - // This references 1:1 message, - // but should create a group. - let raw = b"From: bob@example.net\n\ - To: alice@example.org\n\ - Subject: Group\n\ - Chat-Version: 1.0\n\ - Chat-Group-Name: Group 1\n\ - Chat-Group-ID: GePFDkwEj2K\n\ - Message-ID: \n\ - References: \n\ - In-Reply-To: \n\ - \n\ - Group 1"; - let received1 = receive_imf(t, raw, false).await?.unwrap(); - let msg1 = Message::load_from_db(t, *received1.msg_ids.last().unwrap()).await?; - let chat1 = Chat::load_from_db(t, msg1.chat_id).await?; - assert_eq!(chat1.typ, Chattype::Group); - - // Alice receives outgoing group message. - // This references 1:1 message, - // but should create another group. - let raw = b"From: alice@example.org\n\ - To: bob@example.net - Subject: Group\n\ - Chat-Version: 1.0\n\ - Chat-Group-Name: Group 2\n\ - Chat-Group-ID: Abcdexyzfoo\n\ - Message-ID: \n\ - References: \n\ - In-Reply-To: \n\ - \n\ - Group 2"; - let received2 = receive_imf(t, raw, false).await?.unwrap(); - let msg2 = Message::load_from_db(t, *received2.msg_ids.last().unwrap()).await?; - let chat2 = Chat::load_from_db(t, msg2.chat_id).await?; - assert_eq!(chat2.typ, Chattype::Group); - - assert_ne!(chat1.id, chat2.id); - Ok(()) -} - -/// Tests that if member timestamps are unknown -/// because of the missing `Chat-Group-Member-Timestamps` header, -/// then timestamps default to zero. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_default_member_timestamps_to_zero() -> Result<()> { - let bob = &TestContext::new_bob().await; - - let now = time(); - - let date = chrono::DateTime::::from_timestamp(now - 1000, 0) - .unwrap() - .to_rfc2822(); - let msg = receive_imf( - bob, - format!( - "Subject: Some group\r\n\ - From: \r\n\ - To: , , \r\n\ - Date: {date}\r\n\ - Message-ID: \r\n\ - Chat-Group-ID: foobarbaz12\n\ - Chat-Group-Name: foo\n\ - Chat-Version: 1.0\r\n\ - \r\n\ - Hi!\r\n" - ) - .as_bytes(), - false, - ) - .await? - .unwrap(); - let chat = Chat::load_from_db(bob, msg.chat_id).await?; - assert_eq!(chat.typ, Chattype::Group); - assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 4); - - let date = chrono::DateTime::::from_timestamp(now, 0) - .unwrap() - .to_rfc2822(); - receive_imf( - bob, - format!( - "Subject: Some group\r\n\ - From: \r\n\ - To: , \r\n\ - Chat-Group-Past-Members: \r\n\ - Chat-Group-Member-Timestamps: 1737783000 1737783100 1737783200\r\n\ - Chat-Group-ID: foobarbaz12\n\ - Chat-Group-Name: foo\n\ - Chat-Version: 1.0\r\n\ - Date: {date}\r\n\ - Message-ID: \r\n\ - \r\n\ - Hi back!\r\n" - ) - .as_bytes(), - false, - ) - .await? - .unwrap(); - - let chat = Chat::load_from_db(bob, msg.chat_id).await?; - assert_eq!(chat.typ, Chattype::Group); - assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 3); - Ok(()) -} - /// Regression test for the bug /// that resulted in an info message /// about Bob addition to the group on Fiona's device. @@ -5605,3 +5054,133 @@ PGh0bWw+PGJvZHk+dGV4dDwvYm9keT5kYXRh Ok(()) } + +/// Tests that address-contacts are not added into a group +/// with key-contacts by a plaintext reply. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_address_contact_added_into_group() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let alice_chat_id = alice + .create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob]) + .await; + let bob_received_msg = bob + .recv_msg(&alice.send_text(alice_chat_id, "Message").await) + .await; + let rfc724_mid = bob_received_msg.rfc724_mid; + + // Alice leaves the group so message from email address contact bob@example.com + // does not fail the test for being non-member and is allowed to + // modify the chat. + remove_contact_from_chat(alice, alice_chat_id, ContactId::SELF).await?; + + // Wait 60 days so chatlist is stale. + SystemTime::shift(Duration::from_secs(60 * 24 * 60 * 60 + 1)); + + // Only Bob is the chat member. + assert_eq!( + chat::get_chat_contacts(alice, alice_chat_id).await?.len(), + 1 + ); + + let msg = receive_imf( + alice, + format!( + "From: bob@example.com\n\ + To: alice@example.net, charlie@example.net, fiona@example.net\n\ + Subject: foo\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + In-Reply-To: {rfc724_mid}\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + Hello\n" + ) + .as_bytes(), + false, + ) + .await? + .unwrap(); + + // Unencrypted message should not modify the chat member list. + assert_eq!( + chat::get_chat_contacts(alice, alice_chat_id).await?.len(), + 1 + ); + + // Unencrypted message should not even be assigned to encrypted chat. + assert_ne!(msg.chat_id, alice_chat_id); + + Ok(()) +} + +/// Tests that message is assigned to an ad hoc group +/// if the message has a `Chat-Group-ID` even +/// if there are only two members in a group. +/// +/// Since key-contacts introduction all groups are encrypted, +/// but old versions running on other devices might still +/// create unencrypted groups. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_outgoing_plaintext_two_member_group() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let msg = receive_imf( + alice, + b"From: alice@example.org\n\ + To: bob@example.net\n\ + Subject: foo\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + Chat-Group-ID: 8ud29aridt29arid\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + Hello\n", + false, + ) + .await? + .unwrap(); + + let chat = Chat::load_from_db(alice, msg.chat_id).await?; + assert_eq!(chat.typ, Chattype::Group); + + Ok(()) +} + +/// Tests that large messages are assigned +/// to non-key-contacts if the type is not `multipart/encrypted`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_partial_download_key_contact_lookup() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + // Create two chats with Alice, both with key-contact and email address contact. + let encrypted_chat = bob.create_chat(alice).await; + let unencrypted_chat = bob.create_email_chat(alice).await; + + let seen = false; + let is_partial_download = Some(9999); + let received = receive_imf_from_inbox( + bob, + "3333@example.org", + b"From: alice@example.org\n\ + To: bob@example.net\n\ + Message-ID: <3333@example.org>\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", + seen, + is_partial_download, + ) + .await? + .unwrap(); + + assert_ne!(received.chat_id, encrypted_chat.id); + assert_eq!(received.chat_id, unencrypted_chat.id); + + Ok(()) +} diff --git a/src/securejoin.rs b/src/securejoin.rs index 441e4a05a..578138b69 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -1,13 +1,14 @@ //! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/). use anyhow::{ensure, Context as _, Error, Result}; +use deltachat_contact_tools::ContactAddress; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; -use crate::aheader::EncryptPreference; use crate::chat::{self, get_chat_id_by_grpid, Chat, ChatId, ChatIdBlocked, ProtectionStatus}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT}; +use crate::contact::mark_contact_id_as_verified; use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::e2ee::ensure_secret_key_exists; @@ -18,13 +19,10 @@ use crate::log::{error, info, warn}; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; -use crate::peerstate::Peerstate; use crate::qr::check_qr; use crate::securejoin::bob::JoinerProgress; -use crate::stock_str; use crate::sync::Sync::*; use crate::token; -use crate::tools::time; mod bob; mod qrinvite; @@ -199,7 +197,7 @@ async fn info_chat_id(context: &Context, contact_id: ContactId) -> Result Result { let contact = Contact::get_by_id(context, contact_id).await?; - let peerstate = match Peerstate::from_addr(context, contact.get_addr()).await { - Ok(peerstate) => peerstate, - Err(err) => { - warn!( - context, - "Failed to sender peerstate for {}: {}", - contact.get_addr(), - err - ); - return Ok(false); - } - }; - - if let Some(mut peerstate) = peerstate { - if peerstate - .public_key_fingerprint - .as_ref() - .filter(|&fp| fp == fingerprint) - .is_some() - { - if let Some(public_key) = &peerstate.public_key { - let verifier = contact.get_addr().to_owned(); - peerstate.set_verified(public_key.clone(), fingerprint.clone(), verifier)?; - peerstate.prefer_encrypt = EncryptPreference::Mutual; - peerstate.save_to_db(&context.sql).await?; - return Ok(true); - } - } + let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint); + if is_verified { + mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?; } - - Ok(false) + Ok(is_verified) } /// What to do with a Secure-Join handshake message after it was handled. @@ -335,10 +307,21 @@ pub(crate) async fn handle_securejoin_handshake( inviter_progress(context, contact_id, 300); + let from_addr = ContactAddress::new(&mime_message.from.addr)?; + let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or(""); + let (autocrypt_contact_id, _) = Contact::add_or_lookup_ex( + context, + "", + &from_addr, + autocrypt_fingerprint, + Origin::IncomingUnknownFrom, + ) + .await?; + // Alice -> Bob send_alice_handshake_msg( context, - contact_id, + autocrypt_contact_id, &format!("{}-auth-required", &step.get(..2).unwrap_or_default()), ) .await @@ -408,26 +391,6 @@ pub(crate) async fn handle_securejoin_handshake( ); return Ok(HandshakeMessage::Ignore); } - - let contact_addr = Contact::get_by_id(context, contact_id) - .await? - .get_addr() - .to_owned(); - let backward_verified = true; - let fingerprint_found = mark_peer_as_verified( - context, - fingerprint.clone(), - contact_addr, - backward_verified, - ) - .await?; - if !fingerprint_found { - warn!( - context, - "Ignoring {step} message because of the failure to find matching peerstate." - ); - return Ok(HandshakeMessage::Ignore); - } info!(context, "Fingerprint verified via Auth code.",); contact_id.regossip_keys(context).await?; ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?; @@ -499,17 +462,6 @@ pub(crate) async fn handle_securejoin_handshake( return Ok(HandshakeMessage::Propagate); } - // Mark peer as backward verified. - // - // This is needed for the case when we join a non-protected group - // because in this case `Chat-Verified` header that otherwise - // sets backward verification is not sent. - if let Some(peerstate) = &mut mime_message.peerstate { - peerstate.backward_verified_key_id = - Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0); - peerstate.save_to_db(&context.sql).await?; - } - context.emit_event(EventType::SecurejoinJoinerProgress { contact_id, progress: JoinerProgress::Succeeded.to_usize(), @@ -536,17 +488,15 @@ pub(crate) async fn handle_securejoin_handshake( /// /// If we see self-sent {vc,vg}-request-with-auth, /// we know that we are Bob (joiner-observer) -/// that just marked peer (Alice) as forward-verified +/// that just marked peer (Alice) as verified /// either after receiving {vc,vg}-auth-required /// or immediately after scanning the QR-code /// if the key was already known. /// /// If we see self-sent vc-contact-confirm or vg-member-added message, /// we know that we are Alice (inviter-observer) -/// that just marked peer (Bob) as forward (and backward)-verified +/// that just marked peer (Bob) as verified /// in response to correct vc-request-with-auth message. -/// -/// In both cases we can mark the peer as forward-verified. pub(crate) async fn observe_securejoin_on_other_device( context: &Context, mime_message: &MimeMessage, @@ -568,67 +518,34 @@ pub(crate) async fn observe_securejoin_on_other_device( }; if !encrypted_and_signed(context, mime_message, &get_self_fingerprint(context).await?) { - could_not_establish_secure_connection( + warn!( context, - contact_id, - info_chat_id(context, contact_id).await?, - "Message not encrypted correctly.", - ) - .await?; + "Observed SecureJoin message is not encrypted correctly." + ); return Ok(HandshakeMessage::Ignore); } - let addr = Contact::get_by_id(context, contact_id) - .await? - .get_addr() - .to_lowercase(); + let contact = Contact::get_by_id(context, contact_id).await?; + let addr = contact.get_addr().to_lowercase(); let Some(key) = mime_message.gossiped_keys.get(&addr) else { - could_not_establish_secure_connection( - context, - contact_id, - info_chat_id(context, contact_id).await?, - &format!( - "No gossip header for '{}' at step {}, please update Delta Chat on all \ - your devices.", - &addr, step, - ), - ) - .await?; + warn!(context, "No gossip header for {addr} at step {step}."); return Ok(HandshakeMessage::Ignore); }; - let Some(mut peerstate) = Peerstate::from_addr(context, &addr).await? else { - could_not_establish_secure_connection( - context, - contact_id, - info_chat_id(context, contact_id).await?, - &format!("No peerstate in db for '{}' at step {}", &addr, step), - ) - .await?; + let Some(contact_fingerprint) = contact.fingerprint() else { + // Not a key-contact, should not happen. + warn!(context, "Contact does not have a fingerprint."); return Ok(HandshakeMessage::Ignore); }; - let Some(fingerprint) = peerstate.gossip_key_fingerprint.clone() else { - could_not_establish_secure_connection( - context, - contact_id, - info_chat_id(context, contact_id).await?, - &format!( - "No gossip key fingerprint in db for '{}' at step {}", - &addr, step, - ), - ) - .await?; + if key.dc_fingerprint() != contact_fingerprint { + // Fingerprint does not match, ignore. + warn!(context, "Fingerprint does not match."); return Ok(HandshakeMessage::Ignore); - }; - peerstate.set_verified(key.clone(), fingerprint, addr)?; - if matches!(step, "vg-member-added" | "vc-contact-confirm") { - peerstate.backward_verified_key_id = - Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0); } - peerstate.prefer_encrypt = EncryptPreference::Mutual; - peerstate.save_to_db(&context.sql).await?; + + mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?; ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?; @@ -675,50 +592,6 @@ async fn secure_connection_established( Ok(()) } -async fn could_not_establish_secure_connection( - context: &Context, - contact_id: ContactId, - chat_id: ChatId, - details: &str, -) -> Result<()> { - let contact = Contact::get_by_id(context, contact_id).await?; - let mut msg = stock_str::contact_not_verified(context, &contact).await; - msg += " ("; - msg += details; - msg += ")"; - chat::add_info_msg(context, chat_id, &msg, time()).await?; - warn!( - context, - "StockMessage::ContactNotVerified posted to 1:1 chat ({})", details - ); - Ok(()) -} - -/// Tries to mark peer with provided key fingerprint as verified. -/// -/// Returns true if such key was found, false otherwise. -async fn mark_peer_as_verified( - context: &Context, - fingerprint: Fingerprint, - verifier: String, - backward_verified: bool, -) -> Result { - let Some(ref mut peerstate) = Peerstate::from_fingerprint(context, &fingerprint).await? else { - return Ok(false); - }; - let Some(ref public_key) = peerstate.public_key else { - return Ok(false); - }; - peerstate.set_verified(public_key.clone(), fingerprint, verifier)?; - peerstate.prefer_encrypt = EncryptPreference::Mutual; - if backward_verified { - peerstate.backward_verified_key_id = - Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0); - } - peerstate.save_to_db(&context.sql).await?; - Ok(true) -} - /* ****************************************************************************** * Tools: Misc. ******************************************************************************/ diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 5e9d5eebf..f594309a1 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -5,11 +5,11 @@ use anyhow::{Context as _, Result}; use super::qrinvite::QrInvite; use super::HandshakeMessage; use crate::chat::{self, is_contact_in_chat, ChatId, ProtectionStatus}; -use crate::constants::{self, Blocked, Chattype}; +use crate::constants::{Blocked, Chattype}; use crate::contact::Origin; use crate::context::Context; use crate::events::EventType; -use crate::key::{load_self_public_key, DcKey}; +use crate::key::self_fingerprint; use crate::log::info; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; @@ -57,11 +57,18 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul // Now start the protocol and initialise the state. { - let peer_verified = - verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()) - .await?; + let has_key = context + .sql + .exists( + "SELECT COUNT(*) FROM public_keys WHERE fingerprint=?", + (invite.fingerprint().hex(),), + ) + .await?; - if peer_verified { + if has_key + && verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()) + .await? + { // The scanned fingerprint matches Alice's key, we can proceed to step 4b. info!(context, "Taking securejoin protocol shortcut"); send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth) @@ -130,7 +137,6 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul None, ) .await?; - chat_id.spawn_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT); } Ok(chat_id) } @@ -268,8 +274,8 @@ pub(crate) async fn send_handshake_message( msg.param.set_int(Param::GuaranteeE2ee, 1); // Sends our own fingerprint in the Secure-Join-Fingerprint header. - let bob_fp = load_self_public_key(context).await?.dc_fingerprint(); - msg.param.set(Param::Arg3, bob_fp.hex()); + let bob_fp = self_fingerprint(context).await?; + msg.param.set(Param::Arg3, bob_fp); // Sends the grpid in the Secure-Join-Group header. // diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index 75d0f007e..357fa07bf 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -1,16 +1,16 @@ -use deltachat_contact_tools::{ContactAddress, EmailAddress}; +use deltachat_contact_tools::EmailAddress; use super::*; use crate::chat::{remove_contact_from_chat, CantSendReason}; use crate::chatlist::Chatlist; -use crate::constants::{self, Chattype}; -use crate::imex::{imex, ImexMode}; +use crate::constants::Chattype; +use crate::key::self_fingerprint; use crate::receive_imf::receive_imf; use crate::stock_str::{self, chat_protection_enabled}; -use crate::test_utils::{get_chat_msg, TimeShiftFalsePositiveNote}; -use crate::test_utils::{TestContext, TestContextManager}; +use crate::test_utils::{ + get_chat_msg, TestContext, TestContextManager, TimeShiftFalsePositiveNote, +}; use crate::tools::SystemTime; -use std::collections::HashSet; use std::time::Duration; #[derive(PartialEq)] @@ -18,7 +18,6 @@ enum SetupContactCase { Normal, CheckProtectionTimestamp, WrongAliceGossip, - SecurejoinWaitTimeout, AliceIsBot, AliceHasName, } @@ -38,11 +37,6 @@ async fn test_setup_contact_wrong_alice_gossip() { test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_setup_contact_wait_timeout() { - test_setup_contact_ex(SetupContactCase::SecurejoinWaitTimeout).await -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_setup_contact_alice_is_bot() { test_setup_contact_ex(SetupContactCase::AliceIsBot).await @@ -95,24 +89,26 @@ async fn test_setup_contact_ex(case: SetupContactCase) { 0 ); - // Step 1: Generate QR-code, ChatId(0) indicates setup-contact - let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); + tcm.section("Step 1: Generate QR-code, ChatId(0) indicates setup-contact"); + let qr = get_securejoin_qr(&alice, None).await.unwrap(); // We want Bob to learn Alice's name from their messages, not from the QR code. alice .set_config(Config::Displayname, Some("Alice Exampleorg")) .await .unwrap(); - // Step 2: Bob scans QR-code, sends vc-request - join_securejoin(&bob.ctx, &qr).await.unwrap(); + tcm.section("Step 2: Bob scans QR-code, sends vc-request"); + let bob_chat_id = join_securejoin(&bob.ctx, &qr).await.unwrap(); assert_eq!( Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), 1 ); - let contact_alice_id = Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); + let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await.unwrap(); + assert_eq!( + bob_chat.why_cant_send(&bob).await.unwrap(), + Some(CantSendReason::MissingKey) + ); + let contact_alice_id = bob.add_or_lookup_contact_no_key(&alice).await.id; let sent = bob.pop_sent_msg().await; assert!(!sent.payload.contains("Bob Examplenet")); assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap()); @@ -122,7 +118,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) { assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some()); assert!(!msg.header_exists(HeaderDef::AutoSubmitted)); - // Step 3: Alice receives vc-request, sends vc-auth-required + tcm.section("Step 3: Alice receives vc-request, sends vc-auth-required"); alice.recv_msg_trash(&sent).await; assert_eq!( Chatlist::try_load(&alice, 0, None, None) @@ -141,20 +137,14 @@ async fn test_setup_contact_ex(case: SetupContactCase) { msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-auth-required" ); - let bob_chat = bob.get_chat(&alice).await; - assert_eq!(bob_chat.can_send(&bob).await.unwrap(), false); - assert_eq!( - bob_chat.why_cant_send(&bob).await.unwrap(), - Some(CantSendReason::SecurejoinWait) - ); - if case == SetupContactCase::SecurejoinWaitTimeout { - SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT)); - assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); - } - // Step 4: Bob receives vc-auth-required, sends vc-request-with-auth + let bob_chat = bob.get_chat(&alice).await; + assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); + + tcm.section("Step 4: Bob receives vc-auth-required, sends vc-request-with-auth"); bob.recv_msg_trash(&sent).await; let bob_chat = bob.get_chat(&alice).await; + assert_eq!(bob_chat.why_cant_send(&bob).await.unwrap(), None); assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); // Check Bob emitted the JoinerProgress event. @@ -167,12 +157,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) { contact_id, progress, } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - assert_eq!(contact_id, alice_contact_id); + assert_eq!(contact_id, contact_alice_id); assert_eq!(progress, 400); } _ => unreachable!(), @@ -193,13 +178,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) { "vc-request-with-auth" ); assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx) - .await - .unwrap() - .dc_fingerprint(); + let bob_fp = self_fingerprint(&bob).await.unwrap(); assert_eq!( - *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), - bob_fp.hex() + msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), + bob_fp ); if case == SetupContactCase::WrongAliceGossip { @@ -208,12 +190,12 @@ async fn test_setup_contact_ex(case: SetupContactCase) { .gossiped_keys .insert(alice_addr.to_string(), wrong_pubkey) .unwrap(); - let contact_bob = alice.add_or_lookup_email_contact(&bob).await; + let contact_bob = alice.add_or_lookup_contact_no_key(&bob).await; let handshake_msg = handle_securejoin_handshake(&alice, &mut msg, contact_bob.id) .await .unwrap(); assert_eq!(handshake_msg, HandshakeMessage::Ignore); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); + assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), false); msg.gossiped_keys .insert(alice_addr.to_string(), alice_pubkey) @@ -222,31 +204,25 @@ async fn test_setup_contact_ex(case: SetupContactCase) { .await .unwrap(); assert_eq!(handshake_msg, HandshakeMessage::Ignore); - assert!(contact_bob.is_verified(&alice.ctx).await.unwrap()); + assert!(contact_bob.is_verified(&alice).await.unwrap()); return; } // Alice should not yet have Bob verified - let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); + let contact_bob = alice.add_or_lookup_contact_no_key(&bob).await; + let contact_bob_id = contact_bob.id; + assert_eq!(contact_bob.is_key_contact(), true); + assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), false); assert_eq!(contact_bob.get_authname(), ""); if case == SetupContactCase::CheckProtectionTimestamp { SystemTime::shift(Duration::from_secs(3600)); } - // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm + tcm.section("Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm"); alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); + assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), true); + let contact_bob = Contact::get_by_id(&alice, contact_bob_id).await.unwrap(); assert_eq!(contact_bob.get_authname(), "Bob Examplenet"); assert!(contact_bob.get_name().is_empty()); assert_eq!(contact_bob.is_bot(), false); @@ -283,7 +259,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) { .unwrap(); match case { SetupContactCase::AliceHasName => assert_eq!(contact_alice.get_authname(), "Alice"), - _ => assert_eq!(contact_alice.get_authname(), ""), + _ => assert_eq!(contact_alice.get_authname(), "Alice Exampleorg"), }; // Check Alice sent the right message to Bob. @@ -297,8 +273,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) { "vc-contact-confirm" ); - // Bob should not yet have Alice verified - assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), false); + // Bob has verified Alice already. + // + // Alice may not have verified Bob yet. + assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true); // Step 7: Bob receives vc-contact-confirm bob.recv_msg_trash(&sent).await; @@ -310,35 +288,12 @@ async fn test_setup_contact_ex(case: SetupContactCase) { assert!(contact_alice.get_name().is_empty()); assert_eq!(contact_alice.is_bot(), case == SetupContactCase::AliceIsBot); - if case != SetupContactCase::SecurejoinWaitTimeout { - // Later we check that the timeout message isn't added to the already protected chat. - SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT + 1)); - assert_eq!( - bob_chat - .check_securejoin_wait(&bob, constants::SECUREJOIN_WAIT_TIMEOUT) - .await - .unwrap(), - 0 - ); - } - // Check Bob got expected info messages in his 1:1 chat. - let msg_cnt: usize = match case { - SetupContactCase::SecurejoinWaitTimeout => 3, - _ => 2, - }; + let msg_cnt = 2; let mut i = 0..msg_cnt; let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; assert!(msg.is_info()); assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await); - if case == SetupContactCase::SecurejoinWaitTimeout { - let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; - assert!(msg.is_info()); - assert_eq!( - msg.get_text(), - stock_str::securejoin_takes_longer(&bob).await - ); - } let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; assert!(msg.is_info()); assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await); @@ -354,37 +309,18 @@ async fn test_setup_contact_bad_qr() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_setup_contact_bob_knows_alice() -> Result<()> { let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; // Ensure Bob knows Alice_FP - let alice_pubkey = load_self_public_key(&alice.ctx).await?; - let peerstate = Peerstate { - addr: "alice@example.org".into(), - last_seen: 10, - last_seen_autocrypt: 10, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(alice_pubkey.clone()), - public_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), - gossip_key: Some(alice_pubkey.clone()), - gossip_timestamp: 10, - gossip_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - peerstate.save_to_db(&bob.ctx.sql).await?; + let alice_contact_id = bob.add_or_lookup_contact_id(alice).await; - // Step 1: Generate QR-code, ChatId(0) indicates setup-contact - let qr = get_securejoin_qr(&alice.ctx, None).await?; + tcm.section("Step 1: Generate QR-code"); + // `None` indicates setup-contact. + let qr = get_securejoin_qr(alice, None).await?; - // Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request - join_securejoin(&bob.ctx, &qr).await.unwrap(); + tcm.section("Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request"); + join_securejoin(bob, &qr).await.unwrap(); // Check Bob emitted the JoinerProgress event. let event = bob @@ -396,11 +332,6 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> { contact_id, progress, } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); assert_eq!(contact_id, alice_contact_id); assert_eq!(progress, 400); } @@ -416,26 +347,19 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> { "vc-request-with-auth" ); assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); + let bob_fp = load_self_public_key(bob).await?.dc_fingerprint(); assert_eq!( *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), bob_fp.hex() ); // Alice should not yet have Bob verified - let (contact_bob_id, _modified) = Contact::add_or_lookup( - &alice.ctx, - "", - &ContactAddress::new("bob@example.net")?, - Origin::ManuallyCreated, - ) - .await?; - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); + let contact_bob = alice.add_or_lookup_contact_no_key(bob).await; + assert_eq!(contact_bob.is_verified(alice).await?, false); - // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm + tcm.section("Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm"); alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); + assert_eq!(contact_bob.is_verified(alice).await?, true); let sent = alice.pop_sent_msg().await; let msg = bob.parse_msg(&sent).await; @@ -445,18 +369,16 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> { "vc-contact-confirm" ); - // Bob should not yet have Alice verified - let contact_alice_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, false); + // Bob has verified Alice already. + let contact_alice = bob.add_or_lookup_contact_no_key(alice).await; + assert_eq!(contact_alice.is_verified(bob).await?, true); - // Step 7: Bob receives vc-contact-confirm + // Alice confirms that Bob is now verified. + // + // This does not change anything for Bob. + tcm.section("Step 7: Bob receives vc-contact-confirm"); bob.recv_msg_trash(&sent).await; - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); + assert_eq!(contact_alice.is_verified(bob).await?, true); Ok(()) } @@ -503,15 +425,13 @@ async fn test_secure_join() -> Result<()> { assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0); let alice_chatid = - chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?; + chat::create_group_chat(&alice, ProtectionStatus::Protected, "the chat").await?; - // Step 1: Generate QR-code, secure-join implied by chatid - let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)) - .await - .unwrap(); + tcm.section("Step 1: Generate QR-code, secure-join implied by chatid"); + let qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap(); - // Step 2: Bob scans QR-code, sends vg-request - let bob_chatid = join_securejoin(&bob.ctx, &qr).await?; + tcm.section("Step 2: Bob scans QR-code, sends vg-request"); + let bob_chatid = join_securejoin(&bob, &qr).await?; assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1); let sent = bob.pop_sent_msg().await; @@ -533,7 +453,7 @@ async fn test_secure_join() -> Result<()> { // is only sent in `vg-request-with-auth` for compatibility. assert!(!msg.header_exists(HeaderDef::SecureJoinGroup)); - // Step 3: Alice receives vg-request, sends vg-auth-required + tcm.section("Step 3: Alice receives vg-request, sends vg-auth-required"); alice.recv_msg_trash(&sent).await; let sent = alice.pop_sent_msg().await; @@ -545,10 +465,12 @@ async fn test_secure_join() -> Result<()> { "vg-auth-required" ); - // Step 4: Bob receives vg-auth-required, sends vg-request-with-auth + tcm.section("Step 4: Bob receives vg-auth-required, sends vg-request-with-auth"); bob.recv_msg_trash(&sent).await; let sent = bob.pop_sent_msg().await; + let contact_alice_id = bob.add_or_lookup_contact_no_key(&alice).await.id; + // Check Bob emitted the JoinerProgress event. let event = bob .evtracker @@ -559,12 +481,7 @@ async fn test_secure_join() -> Result<()> { contact_id, progress, } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - assert_eq!(contact_id, alice_contact_id); + assert_eq!(contact_id, contact_alice_id); assert_eq!(progress, 400); } _ => unreachable!(), @@ -579,22 +496,19 @@ async fn test_secure_join() -> Result<()> { "vg-request-with-auth" ); assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); + let bob_fp = self_fingerprint(&bob).await?; assert_eq!( - *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), - bob_fp.hex() + msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), + bob_fp ); // Alice should not yet have Bob verified - let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await? - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); + let contact_bob = alice.add_or_lookup_contact_no_key(&bob).await; + assert_eq!(contact_bob.is_verified(&alice).await?, false); - // Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added + tcm.section("Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added"); alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); + assert_eq!(contact_bob.is_verified(&alice).await?, true); let sent = alice.pop_sent_msg().await; let msg = bob.parse_msg(&sent).await; @@ -629,20 +543,17 @@ async fn test_secure_join() -> Result<()> { assert_eq!(msg.get_text(), expected_text); } - // Bob should not yet have Alice verified - let contact_alice_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, false); + // Bob has verified Alice already. + // + // Alice may not have verified Bob yet. + let contact_alice = bob.add_or_lookup_contact_no_key(&alice).await; + assert_eq!(contact_alice.is_verified(&bob).await?, true); - // Step 7: Bob receives vg-member-added + tcm.section("Step 7: Bob receives vg-member-added"); bob.recv_msg(&sent).await; { // Bob has Alice verified, message shows up in the group chat. - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); + assert_eq!(contact_alice.is_verified(&bob).await?, true); let chat = bob.get_chat(&alice).await; assert_eq!( chat.blocked, @@ -728,8 +639,10 @@ async fn test_unknown_sender() -> Result<()> { } /// Tests that Bob gets Alice as verified -/// if `vc-contact-confirm` is lost but Alice then sends -/// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header. +/// if `vc-contact-confirm` is lost. +/// Previously `vc-contact-confirm` was used +/// to confirm backward verification, +/// but backward verification is not tracked anymore. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_lost_contact_confirm() { let mut tcm = TestContextManager::new(); @@ -741,7 +654,7 @@ async fn test_lost_contact_confirm() { .unwrap(); } - let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); + let qr = get_securejoin_qr(&alice, None).await.unwrap(); join_securejoin(&bob.ctx, &qr).await.unwrap(); // vc-request @@ -757,94 +670,17 @@ async fn test_lost_contact_confirm() { alice.recv_msg_trash(&sent).await; // Alice has Bob verified now. - let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); + let contact_bob = alice.add_or_lookup_contact_no_key(&bob).await; + assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), true); // Alice sends vc-contact-confirm, but it gets lost. let _sent_vc_contact_confirm = alice.pop_sent_msg().await; - // Bob should not yet have Alice verified - let contact_alice_id = Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); - assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false); - - // Alice sends a text message to Bob. - let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await; - let chat_id = received_hello.chat_id; - let chat = Chat::load_from_db(&bob, chat_id).await.unwrap(); - assert_eq!(chat.is_protected(), true); - - // Received text message in a verified 1:1 chat results in backward verification - // and Bob now marks alice as verified. - let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); + // Bob has alice as verified too, even though vc-contact-confirm is lost. + let contact_alice = bob.add_or_lookup_contact_no_key(&alice).await; assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true); } -/// An unencrypted message with already known Autocrypt key, but sent from another address, -/// means that it's rather a new contact sharing the same key than the existing one changed its -/// address, otherwise it would already have our key to encrypt. -/// -/// This is a regression test for a bug where DC wrongly executed AEAP in this case. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_shared_bobs_key() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - let bob_addr = &bob.get_config(Config::Addr).await?.unwrap(); - - tcm.execute_securejoin(bob, alice).await; - - let export_dir = tempfile::tempdir().unwrap(); - imex(bob, ImexMode::ExportSelfKeys, export_dir.path(), None).await?; - let bob2 = &TestContext::new().await; - let bob2_addr = "bob2@example.net"; - bob2.configure_addr(bob2_addr).await; - imex(bob2, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; - - tcm.execute_securejoin(bob2, alice).await; - - let bob3 = &TestContext::new().await; - let bob3_addr = "bob3@example.net"; - bob3.configure_addr(bob3_addr).await; - imex(bob3, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; - let chat = bob3.create_email_chat(alice).await; - let sent = bob3.send_text(chat.id, "hi Alice!").await; - let msg = alice.recv_msg(&sent).await; - assert!(!msg.get_showpadlock()); - let chat = alice.create_email_chat(bob3).await; - let sent = alice.send_text(chat.id, "hi Bob3!").await; - let msg = bob3.recv_msg(&sent).await; - assert!(msg.get_showpadlock()); - - let mut bob_ids = HashSet::new(); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob_addr, Origin::Unknown) - .await? - .unwrap(), - ); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob2_addr, Origin::Unknown) - .await? - .unwrap(), - ); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob3_addr, Origin::Unknown) - .await? - .unwrap(), - ); - assert_eq!(bob_ids.len(), 3); - Ok(()) -} - /// Tests Bob joining two groups by scanning two QR codes /// from the same Alice at the same time. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -978,7 +814,7 @@ async fn test_wrong_auth_token() -> Result<()> { alice.recv_msg_trash(&sent).await; let alice_bob_contact = alice.add_or_lookup_contact(bob).await; - assert!(!alice_bob_contact.is_forward_verified(alice).await?); + assert!(!alice_bob_contact.is_verified(alice).await?); Ok(()) } diff --git a/src/sql.rs b/src/sql.rs index 5fe28fe7c..cbaa4e1ff 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -8,7 +8,7 @@ use rusqlite::{config::DbConfig, types::ValueRef, Connection, OpenFlags, Row}; use tokio::sync::RwLock; use crate::blob::BlobObject; -use crate::chat::{self, add_device_msg, update_device_icon, update_saved_messages_icon}; +use crate::chat::add_device_msg; use crate::config::Config; use crate::constants::DC_CHAT_ID_TRASH; use crate::context::Context; @@ -22,7 +22,6 @@ use crate::net::dns::prune_dns_cache; use crate::net::http::http_cache_cleanup; use crate::net::prune_connection_history; use crate::param::{Param, Params}; -use crate::peerstate::Peerstate; use crate::stock_str; use crate::tools::{delete_file, time, SystemTime}; @@ -191,7 +190,19 @@ impl Sql { async fn try_open(&self, context: &Context, dbfile: &Path, passphrase: String) -> Result<()> { *self.pool.write().await = Some(Self::new_pool(dbfile, passphrase.to_string())?); - self.run_migrations(context).await?; + if let Err(e) = self.run_migrations(context).await { + error!(context, "Running migrations failed: {e:#}"); + // Emiting an error event probably doesn't work + // because we are in the process of opening the context, + // so there is no event emitter yet. + // So, try to report the error in other ways: + eprintln!("Running migrations failed: {e:#}"); + context.set_migration_error(&format!("Updating Delta Chat failed. Please send this message to the Delta Chat developers, either at delta@merlinux.eu or at https://support.delta.chat.\n\n{e:#}")); + // We can't simply close the db for two reasons: + // a. backup export would fail + // b. The UI would think that the account is unconfigured (because `is_configured()` fails) + // and remove the account when the user presses "Back" + } Ok(()) } @@ -202,41 +213,14 @@ impl Sql { // this should be done before updates that use high-level objects that // rely themselves on the low-level structure. - let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) = - migrations::run(context, self) - .await - .context("failed to run migrations")?; + // `update_icons` is not used anymore, since it's not necessary anymore to "update" icons: + let (_update_icons, disable_server_delete, recode_avatar) = migrations::run(context, self) + .await + .context("failed to run migrations")?; // (2) updates that require high-level objects // the structure is complete now and all objects are usable - if recalc_fingerprints { - info!(context, "[migration] recalc fingerprints"); - let addrs = self - .query_map( - "SELECT addr FROM acpeerstates;", - (), - |row| row.get::<_, String>(0), - |addrs| { - addrs - .collect::, _>>() - .map_err(Into::into) - }, - ) - .await?; - for addr in &addrs { - if let Some(ref mut peerstate) = Peerstate::from_addr(context, addr).await? { - peerstate.recalc_fingerprint(); - peerstate.save_to_db(self).await?; - } - } - } - - if update_icons { - update_saved_messages_icon(context).await?; - update_device_icon(context).await?; - } - if disable_server_delete { // We now always watch all folders and delete messages there if delete_server is enabled. // So, for people who have delete_server enabled, disable it and add a hint to the devicechat: @@ -287,10 +271,7 @@ impl Sql { } let passphrase_nonempty = !passphrase.is_empty(); - if let Err(err) = self.try_open(context, &self.dbfile, passphrase).await { - self.close().await; - return Err(err); - } + self.try_open(context, &self.dbfile, passphrase).await?; info!(context, "Opened database {:?}.", self.dbfile); *self.is_encrypted.write().await = Some(passphrase_nonempty); @@ -301,10 +282,6 @@ impl Sql { { set_debug_logging_xdc(context, Some(MsgId::new(xdc_id))).await?; } - chat::resume_securejoin_wait(context) - .await - .log_err(context) - .ok(); Ok(()) } diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index db738a697..d8fc1722e 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1,7 +1,13 @@ //! Migrations module. +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::time::Instant; + use anyhow::{ensure, Context as _, Result}; +use deltachat_contact_tools::addr_cmp; use deltachat_contact_tools::EmailAddress; +use pgp::composed::SignedPublicKey; use rusqlite::OptionalExtension; use crate::config::Config; @@ -9,6 +15,7 @@ use crate::configure::EnteredLoginParam; use crate::constants::ShowEmails; use crate::context::Context; use crate::imap; +use crate::key::DcKey; use crate::log::{info, warn}; use crate::login_param::ConfiguredLoginParam; use crate::message::MsgId; @@ -20,8 +27,12 @@ const DBVERSION: i32 = 68; const VERSION_CFG: &str = "dbversion"; const TABLES: &str = include_str!("./tables.sql"); -pub async fn run(context: &Context, sql: &Sql) -> Result<(bool, bool, bool, bool)> { - let mut recalc_fingerprints = false; +#[cfg(test)] +tokio::task_local! { + static STOP_MIGRATIONS_AT: i32; +} + +pub async fn run(context: &Context, sql: &Sql) -> Result<(bool, bool, bool)> { let mut exists_before_update = false; let mut dbversion_before_update = DBVERSION; @@ -159,7 +170,6 @@ CREATE INDEX acpeerstates_index4 ON acpeerstates (gossip_key_fingerprint);"#, 34, ) .await?; - recalc_fingerprints = true; } if dbversion < 39 { sql.execute_migration( @@ -1225,6 +1235,18 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); .await?; } + inc_and_check(&mut migration_version, 132)?; + if dbversion < migration_version { + let start = Instant::now(); + sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version) + .await?; + info!( + context, + "key-contacts migration took {:?} in total.", + start.elapsed() + ); + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? @@ -1239,12 +1261,610 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); } info!(context, "Database version: v{new_version}."); - Ok(( - recalc_fingerprints, - update_icons, - disable_server_delete, - recode_avatar, - )) + Ok((update_icons, disable_server_delete, recode_avatar)) +} + +fn migrate_key_contacts( + context: &Context, + transaction: &mut rusqlite::Transaction<'_>, +) -> std::result::Result<(), anyhow::Error> { + info!(context, "Starting key-contact transition."); + + // =============================== Step 1: =============================== + // Alter tables + transaction.execute_batch( + "ALTER TABLE contacts ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''; + + -- Verifier is an ID of the verifier contact. + -- 0 if the contact is not verified. + ALTER TABLE contacts ADD COLUMN verifier INTEGER NOT NULL DEFAULT 0; + + CREATE INDEX contacts_fingerprint_index ON contacts (fingerprint); + + CREATE TABLE public_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL UNIQUE, -- Upper-case fingerprint of the key. + public_key BLOB NOT NULL -- Binary key, not ASCII-armored + ) STRICT; + CREATE INDEX public_key_index ON public_keys (fingerprint); + + INSERT OR IGNORE INTO public_keys (fingerprint, public_key) + SELECT public_key_fingerprint, public_key FROM acpeerstates + WHERE public_key_fingerprint IS NOT NULL AND public_key IS NOT NULL; + + INSERT OR IGNORE INTO public_keys (fingerprint, public_key) + SELECT gossip_key_fingerprint, gossip_key FROM acpeerstates + WHERE gossip_key_fingerprint IS NOT NULL AND gossip_key IS NOT NULL; + + INSERT OR IGNORE INTO public_keys (fingerprint, public_key) + SELECT verified_key_fingerprint, verified_key FROM acpeerstates + WHERE verified_key_fingerprint IS NOT NULL AND verified_key IS NOT NULL; + + INSERT OR IGNORE INTO public_keys (fingerprint, public_key) + SELECT secondary_verified_key_fingerprint, secondary_verified_key FROM acpeerstates + WHERE secondary_verified_key_fingerprint IS NOT NULL AND secondary_verified_key IS NOT NULL;", + ) + .context("Creating key-contact tables")?; + + let Some(self_addr): Option = transaction + .query_row( + "SELECT value FROM config WHERE keyname='configured_addr'", + (), + |row| row.get(0), + ) + .optional() + .context("Step 0")? + else { + info!( + context, + "Not yet configured, no need to migrate key-contacts" + ); + return Ok(()); + }; + + // =============================== Step 2: =============================== + // Create up to 3 new contacts for every contact that has a peerstate: + // one from the Autocrypt key fingerprint, one from the verified key fingerprint, + // one from the secondary verified key fingerprint. + // In the process, build maps from old contact id to new contact id: + // one that maps to Autocrypt key-contact, one that maps to verified key-contact. + let mut autocrypt_key_contacts: BTreeMap = BTreeMap::new(); + let mut autocrypt_key_contacts_with_reset_peerstate: BTreeMap = BTreeMap::new(); + let mut verified_key_contacts: BTreeMap = BTreeMap::new(); + { + // This maps from the verified contact to the original contact id of the verifier. + // It can't map to the verified key contact id, because at the time of constructing + // this map, not all key-contacts are in the database. + let mut verifications: BTreeMap = BTreeMap::new(); + + let mut load_contacts_stmt = transaction + .prepare( + "SELECT c.id, c.name, c.addr, c.origin, c.blocked, c.last_seen, + c.authname, c.param, c.status, c.is_bot, c.selfavatar_sent, + IFNULL(p.public_key, p.gossip_key), + p.verified_key, IFNULL(p.verifier, ''), + p.secondary_verified_key, p.secondary_verifier, p.prefer_encrypted + FROM contacts c + INNER JOIN acpeerstates p ON c.addr=p.addr + WHERE c.id > 9 + ORDER BY p.last_seen DESC", + ) + .context("Step 2")?; + + let all_address_contacts: rusqlite::Result> = load_contacts_stmt + .query_map((), |row| { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + let addr: String = row.get(2)?; + let origin: i64 = row.get(3)?; + let blocked: Option = row.get(4)?; + let last_seen: i64 = row.get(5)?; + let authname: String = row.get(6)?; + let param: String = row.get(7)?; + let status: Option = row.get(8)?; + let is_bot: bool = row.get(9)?; + let selfavatar_sent: i64 = row.get(10)?; + let autocrypt_key = row + .get(11) + .ok() + .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()); + let verified_key = row + .get(12) + .ok() + .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()); + let verifier: String = row.get(13)?; + let secondary_verified_key = row + .get(12) + .ok() + .and_then(|blob: Vec| SignedPublicKey::from_slice(&blob).ok()); + let secondary_verifier: String = row.get(15)?; + let prefer_encrypt: u8 = row.get(16)?; + Ok(( + id, + name, + addr, + origin, + blocked, + last_seen, + authname, + param, + status, + is_bot, + selfavatar_sent, + autocrypt_key, + verified_key, + verifier, + secondary_verified_key, + secondary_verifier, + prefer_encrypt, + )) + }) + .context("Step 3")? + .collect(); + + let mut insert_contact_stmt = transaction + .prepare( + "INSERT INTO contacts (name, addr, origin, blocked, last_seen, + authname, param, status, is_bot, selfavatar_sent, fingerprint) + VALUES(?,?,?,?,?,?,?,?,?,?,?)", + ) + .context("Step 4")?; + let mut fingerprint_to_id_stmt = transaction + .prepare("SELECT id FROM contacts WHERE fingerprint=? AND id>9") + .context("Step 5")?; + let mut original_contact_id_from_addr_stmt = transaction + .prepare("SELECT id FROM contacts WHERE addr=? AND fingerprint='' AND id>9") + .context("Step 6")?; + + for row in all_address_contacts? { + let ( + original_id, + name, + addr, + origin, + blocked, + last_seen, + authname, + param, + status, + is_bot, + selfavatar_sent, + autocrypt_key, + verified_key, + verifier, + secondary_verified_key, + secondary_verifier, + prefer_encrypt, + ) = row; + let mut insert_contact = |key: SignedPublicKey| -> Result { + let fingerprint = key.dc_fingerprint().hex(); + let existing_contact_id: Option = fingerprint_to_id_stmt + .query_row((&fingerprint,), |row| row.get(0)) + .optional() + .context("Step 7")?; + if let Some(existing_contact_id) = existing_contact_id { + return Ok(existing_contact_id); + } + insert_contact_stmt + .execute(( + &name, + &addr, + origin, + blocked, + last_seen, + &authname, + ¶m, + &status, + is_bot, + selfavatar_sent, + fingerprint.clone(), + )) + .context("Step 8")?; + let id = transaction + .last_insert_rowid() + .try_into() + .context("Step 9")?; + info!( + context, + "Inserted new contact id={id} name='{name}' addr='{addr}' fingerprint={fingerprint}" + ); + Ok(id) + }; + let mut original_contact_id_from_addr = |addr: &str, default: u32| -> Result { + if addr_cmp(addr, &self_addr) { + Ok(1) // ContactId::SELF + } else if addr.is_empty() { + Ok(default) + } else { + original_contact_id_from_addr_stmt + .query_row((addr,), |row| row.get(0)) + .with_context(|| format!("Original contact '{addr}' not found")) + } + }; + + let Some(autocrypt_key) = autocrypt_key else { + continue; + }; + let new_id = insert_contact(autocrypt_key).context("Step 10")?; + + // prefer_encrypt == 20 would mean EncryptPreference::Reset, + // i.e. we shouldn't encrypt if possible. + if prefer_encrypt != 20 { + autocrypt_key_contacts.insert(original_id.try_into().context("Step 11")?, new_id); + } else { + autocrypt_key_contacts_with_reset_peerstate + .insert(original_id.try_into().context("Step 12")?, new_id); + } + + let Some(verified_key) = verified_key else { + continue; + }; + let new_id = insert_contact(verified_key).context("Step 13")?; + verified_key_contacts.insert(original_id.try_into().context("Step 14")?, new_id); + // If the original verifier is unknown, we represent this in the database + // by putting `new_id` into the place of the verifier, + // i.e. we say that this contact verified itself. + let verifier_id = + original_contact_id_from_addr(&verifier, new_id).context("Step 15")?; + verifications.insert(new_id, verifier_id); + + let Some(secondary_verified_key) = secondary_verified_key else { + continue; + }; + let new_id = insert_contact(secondary_verified_key).context("Step 16")?; + let verifier_id: u32 = + original_contact_id_from_addr(&secondary_verifier, new_id).context("Step 17")?; + // Only use secondary verification if there is no primary verification: + verifications.entry(new_id).or_insert(verifier_id); + } + info!( + context, + "Created key-contacts identified by autocrypt key: {autocrypt_key_contacts:?}" + ); + info!(context, "Created key-contacts with 'reset' peerstate identified by autocrypt key: {autocrypt_key_contacts_with_reset_peerstate:?}"); + info!( + context, + "Created key-contacts identified by verified key: {verified_key_contacts:?}" + ); + + for (&new_contact, &verifier_original_contact) in &verifications { + let verifier = if verifier_original_contact == 1 { + 1 // Verified by ContactId::SELF + } else if verifier_original_contact == new_contact { + new_contact // unkwnown verifier + } else { + // `verifications` contains the original contact id. + // We need to get the new, verified-pgp-identified contact id. + match verified_key_contacts.get(&verifier_original_contact) { + Some(v) => *v, + None => { + warn!(context, "Couldn't find key-contact for {verifier_original_contact} who verified {new_contact}"); + continue; + } + } + }; + transaction + .execute( + "UPDATE contacts SET verifier=? WHERE id=?", + (verifier, new_contact), + ) + .context("Step 18")?; + } + info!(context, "Migrated verifications: {verifications:?}"); + } + + // ======================= Step 3: ======================= + // For each chat, modify the memberlist to retain the correct contacts + // In the process, track the set of contacts which remained no any chat at all + // in a `BTreeSet`, which initially contains all contact ids + let mut orphaned_contacts: BTreeSet = transaction + .prepare("SELECT id FROM contacts WHERE id>9") + .context("Step 19")? + .query_map((), |row| row.get::(0)) + .context("Step 20")? + .collect::, rusqlite::Error>>() + .context("Step 21")?; + + { + let mut stmt = transaction + .prepare( + "SELECT c.id, c.type, c.grpid, c.protected + FROM chats c + WHERE id>9", + ) + .context("Step 22")?; + let all_chats = stmt + .query_map((), |row| { + let id: u32 = row.get(0)?; + let typ: u32 = row.get(1)?; + let grpid: String = row.get(2)?; + let protected: u32 = row.get(3)?; + Ok((id, typ, grpid, protected)) + }) + .context("Step 23")?; + let mut load_chat_contacts_stmt = transaction + .prepare("SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9")?; + let is_chatmail: Option = transaction + .query_row( + "SELECT value FROM config WHERE keyname='is_chatmail'", + (), + |row| row.get(0), + ) + .optional() + .context("Step 23.1")?; + let is_chatmail = is_chatmail + .and_then(|s| s.parse::().ok()) + .unwrap_or_default() + != 0; + let map_to_key_contact = |old_member: &u32| { + ( + *old_member, + autocrypt_key_contacts + .get(old_member) + .or_else(|| { + // For chatmail servers, + // we send encrypted even if the peerstate is reset, + // because an unencrypted message likely won't arrive. + // This is the same behavior as before key-contacts migration. + if is_chatmail { + autocrypt_key_contacts_with_reset_peerstate.get(old_member) + } else { + None + } + }) + .copied(), + ) + }; + + let mut update_member_stmt = transaction + .prepare("UPDATE chats_contacts SET contact_id=? WHERE contact_id=? AND chat_id=?")?; + let mut addr_cmp_stmt = transaction + .prepare("SELECT c.addr=d.addr FROM contacts c, contacts d WHERE c.id=? AND d.id=?")?; + for chat in all_chats { + let (chat_id, typ, grpid, protected) = chat.context("Step 24")?; + // In groups, this also contains past members + let old_members: Vec = load_chat_contacts_stmt + .query_map((chat_id,), |row| row.get::<_, u32>(0)) + .context("Step 25")? + .collect::, rusqlite::Error>>() + .context("Step 26")?; + + let mut keep_address_contacts = |reason: &str| { + info!(context, "Chat {chat_id} will be an unencrypted chat with contacts identified by email address: {reason}"); + for m in &old_members { + orphaned_contacts.remove(m); + } + }; + let old_and_new_members: Vec<(u32, Option)> = match typ { + // 1:1 chats retain: + // - address-contact if peerstate is in the "reset" state, + // or if there is no key-contact that has the right email address. + // - key-contact identified by the Autocrypt key if Autocrypt key does not match the verified key. + // - key-contact identified by the verified key if peerstate Autocrypt key matches the Verified key. + // Since the autocrypt and verified key-contact are identital in this case, we can add the Autocrypt key-contact, + // and the effect will be the same. + 100 => { + let Some(old_member) = old_members.first() else { + info!(context, "1:1 chat {chat_id} doesn't contain contact, probably it's self or device chat"); + continue; + }; + + let (_, Some(new_contact)) = map_to_key_contact(old_member) else { + keep_address_contacts("No peerstate, or peerstate in 'reset' state"); + continue; + }; + if !addr_cmp_stmt + .query_row((old_member, new_contact), |row| row.get::<_, bool>(0))? + { + // Unprotect this 1:1 chat if it was protected. + // + // Otherwise we get protected chat with address-contact. + transaction + .execute("UPDATE chats SET protected=0 WHERE id=?", (chat_id,))?; + + keep_address_contacts("key contact has different email"); + continue; + } + vec![(*old_member, Some(new_contact))] + } + + // Group + 120 => { + if grpid.is_empty() { + // Ad-hoc group that has empty Chat-Group-ID + // because it was created in response to receiving a non-chat email. + keep_address_contacts("Empty chat-Group-ID"); + continue; + } else if protected == 1 { + old_members + .iter() + .map(|old_member| { + (*old_member, verified_key_contacts.get(old_member).copied()) + }) + .collect() + } else { + old_members + .iter() + .map(map_to_key_contact) + .collect::)>>() + } + } + + // Mailinglist + 140 => { + keep_address_contacts("Mailinglist"); + continue; + } + + // Broadcast list + 160 => old_members + .iter() + .map(|original| { + ( + *original, + autocrypt_key_contacts + .get(original) + // There will be no unencrypted broadcast lists anymore, + // so, if a peerstate is reset, + // the best we can do is encrypting to this key regardless. + .or_else(|| { + autocrypt_key_contacts_with_reset_peerstate.get(original) + }) + .copied(), + ) + }) + .collect::)>>(), + _ => { + warn!(context, "Invalid chat type {typ}"); + continue; + } + }; + + // If a group contains a contact without a key or with 'reset' peerstate, + // downgrade to unencrypted Ad-Hoc group. + if typ == 120 && old_and_new_members.iter().any(|(_old, new)| new.is_none()) { + transaction + .execute("UPDATE chats SET grpid='' WHERE id=?", (chat_id,)) + .context("Step 26.1")?; + keep_address_contacts("Group contains contact without peerstate"); + continue; + } + + let human_readable_transitions = old_and_new_members + .iter() + .map(|(old, new)| format!("{old}->{}", new.unwrap_or_default())) + .collect::>() + .join(" "); + info!( + context, + "Migrating chat {chat_id} to key-contacts: {human_readable_transitions}" + ); + + for (old_member, new_member) in old_and_new_members { + if let Some(new_member) = new_member { + orphaned_contacts.remove(&new_member); + let res = update_member_stmt.execute((new_member, old_member, chat_id)); + if res.is_err() { + // The same chat partner exists multiple times in the chat, + // with mutliple profiles which have different email addresses + // but the same key. + // We can only keep one of them. + // So, if one of them is not in the chat anymore, delete it, + // otherwise delete the one that was added least recently. + let member_to_delete: u32 = transaction + .query_row( + "SELECT contact_id + FROM chats_contacts + WHERE chat_id=? AND contact_id IN (?,?) + ORDER BY add_timestamp>=remove_timestamp, add_timestamp LIMIT 1", + (chat_id, new_member, old_member), + |row| row.get(0), + ) + .context("Step 27")?; + info!( + context, + "Chat partner is in the chat {chat_id} multiple times. \ + Deleting {member_to_delete}, then trying to update \ + {old_member}->{new_member} again" + ); + transaction + .execute( + "DELETE FROM chats_contacts WHERE chat_id=? AND contact_id=?", + (chat_id, member_to_delete), + ) + .context("Step 28")?; + // If we removed `old_member`, then this will be a no-op, + // which is exactly what we want in this case: + update_member_stmt.execute((new_member, old_member, chat_id))?; + } + } else { + info!(context, "Old member {old_member} in chat {chat_id} can't be upgraded to key-contact, removing them"); + transaction + .execute( + "DELETE FROM chats_contacts WHERE contact_id=? AND chat_id=?", + (old_member, chat_id), + ) + .context("Step 29")?; + } + } + } + } + + // ======================= Step 4: ======================= + { + info!( + context, + "Marking contacts which remained in no chat at all as hidden: {orphaned_contacts:?}" + ); + let mut mark_as_hidden_stmt = transaction + .prepare("UPDATE contacts SET origin=? WHERE id=?") + .context("Step 30")?; + for contact in orphaned_contacts { + mark_as_hidden_stmt + .execute((0x8, contact)) + .context("Step 31")?; + } + } + + // ======================= Step 5: ======================= + // Rewrite `from_id` in messages + { + let start = Instant::now(); + + let mut encrypted_msgs_stmt = transaction + .prepare( + "SELECT id, from_id, to_id + FROM msgs + WHERE id>9 + AND (param LIKE '%\nc=1%' OR param LIKE 'c=1%') + AND chat_id>9 + ORDER BY id DESC LIMIT 10000", + ) + .context("Step 32")?; + let mut rewrite_msg_stmt = transaction + .prepare("UPDATE msgs SET from_id=?, to_id=? WHERE id=?") + .context("Step 32.1")?; + + struct LoadedMsg { + id: u32, + from_id: u32, + to_id: u32, + } + + let encrypted_msgs = encrypted_msgs_stmt + .query_map((), |row| { + let id: u32 = row.get(0)?; + let from_id: u32 = row.get(1)?; + let to_id: u32 = row.get(2)?; + Ok(LoadedMsg { id, from_id, to_id }) + }) + .context("Step 33")?; + + for msg in encrypted_msgs { + let msg = msg.context("Step 34")?; + + let new_from_id = *autocrypt_key_contacts + .get(&msg.from_id) + .or_else(|| autocrypt_key_contacts_with_reset_peerstate.get(&msg.from_id)) + .unwrap_or(&msg.from_id); + + let new_to_id = *autocrypt_key_contacts + .get(&msg.to_id) + .or_else(|| autocrypt_key_contacts_with_reset_peerstate.get(&msg.to_id)) + .unwrap_or(&msg.to_id); + + rewrite_msg_stmt + .execute((new_from_id, new_to_id, msg.id)) + .context("Step 35")?; + } + info!( + context, + "Rewriting msgs to key-contacts took {:?}.", + start.elapsed() + ); + } + + Ok(()) } impl Sql { @@ -1284,6 +1904,14 @@ impl Sql { migration: impl Send + FnOnce(&mut rusqlite::Transaction) -> Result<()>, version: i32, ) -> Result<()> { + #[cfg(test)] + if STOP_MIGRATIONS_AT.try_with(|stop_migrations_at| version > *stop_migrations_at) + == Ok(true) + { + println!("Not running migration {version}, because STOP_MIGRATIONS_AT is set"); + return Ok(()); + } + self.transaction(move |transaction| { let curr_version: String = transaction.query_row( "SELECT IFNULL(value, ?) FROM config WHERE keyname=?;", @@ -1307,28 +1935,4 @@ impl Sql { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::test_utils::TestContext; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_clear_config_cache() -> anyhow::Result<()> { - // Some migrations change the `config` table in SQL. - // This test checks that the config cache is invalidated in `execute_migration()`. - - let t = TestContext::new().await; - assert_eq!(t.get_config_bool(Config::IsChatmail).await?, false); - - t.sql - .execute_migration( - "INSERT INTO config (keyname, value) VALUES ('is_chatmail', '1')", - 1000, - ) - .await?; - assert_eq!(t.get_config_bool(Config::IsChatmail).await?, true); - assert_eq!(t.sql.get_raw_config_int(VERSION_CFG).await?.unwrap(), 1000); - - Ok(()) - } -} +mod migrations_tests; diff --git a/src/sql/migrations/migrations_tests.rs b/src/sql/migrations/migrations_tests.rs new file mode 100644 index 000000000..c0029673d --- /dev/null +++ b/src/sql/migrations/migrations_tests.rs @@ -0,0 +1,173 @@ +use super::*; +use crate::chat; +use crate::chat::ChatId; +use crate::config::Config; +use crate::contact::Contact; +use crate::contact::ContactId; +use crate::contact::Origin; +use crate::test_utils::TestContext; +use crate::tools; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_clear_config_cache() -> anyhow::Result<()> { + // Some migrations change the `config` table in SQL. + // This test checks that the config cache is invalidated in `execute_migration()`. + + let t = TestContext::new().await; + assert_eq!(t.get_config_bool(Config::IsChatmail).await?, false); + + t.sql + .execute_migration( + "INSERT INTO config (keyname, value) VALUES ('is_chatmail', '1')", + 1000, + ) + .await?; + assert_eq!(t.get_config_bool(Config::IsChatmail).await?, true); + assert_eq!(t.sql.get_raw_config_int(VERSION_CFG).await?.unwrap(), 1000); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_key_contacts_migration_autocrypt() -> Result<()> { + let t = STOP_MIGRATIONS_AT + .scope(131, async move { TestContext::new_alice().await }) + .await; + + t.sql.call_write(|conn| Ok(conn.execute_batch(r#" + INSERT INTO acpeerstates VALUES(1,'bob@example.net',0,0,NULL,1,1745589039,X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680b932f021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487069707ff53fae1e7d40cf1b2b0ea22c1cadd735b16fbdc4c0571fc88b9c489bb2023fce8e197880c4d579d67fa75124ae696fecc17cd5815362e00601e9240d10e0a46bfc0567b88312a41e56bedb045482de61279eb7d10cf15b23e56dc254084401eeaac0780f7ca912f6f9e3d4e4b3f82b1a0fc3ee6600e5367549dbc83242743dee435287c1ba1db604f4d7416780a5d43fe8047338866715a9081285797b96cb9340822d04331121646188e3c9e9bf209611fe9f72bf5df3f0cfdf46d698566ae5ef75e8fa05f5d760e22e592c61e2a48dffeff8cec2f425a5c04951df78f68362f475ba9a8f15e4f588d85f8738815d92d8ccd876833c1683927dd28f5ede9da8ecec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680b932f021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf704873c3807fd1e3e54a16fc879fc006af060de9216a761188b73fcaa617383feb632b80bcbbf362ea4381bd15e58cffa5ec03da0cd50e4adf37be5c81a66d6a22b9835cbb9c219ecd7426547e6a8ec35839d76795aa448a544bc4a5ecfea0284c1ee576a3dc9fdd41beb54f3f60283451b1d292bddda076e1c02b82d957708dcea5f6fb4faf72f69bdff01ed89468e9870e1a081dac09ccc0b9590ac12e7b85008838e8f9aafcfb2bdcc63085a70819c4f6b8b77cff5716af43c834d114a22745eea504b90c431abadb06ba979021726de29fa09523254ff88d3a9a94ba22c46ba5eb4919ca3c8d1f58b1349c5dd1747afb88067dd2ee258b07b8eb0e09235da2469fcc08c79',NULL,'CCCB5AA9F6E1141C943165F1DB18B18CBCF70487',NULL,NULL,'',NULL,NULL,'',NULL); + INSERT INTO contacts VALUES(10,'','bob@example.net',16384,0,0,'','',1745589041,'',0); + INSERT INTO chats VALUES(10,100,'bob@example.net',0,'',0,'','',0,0,0,0,0,1745589039,0,NULL,0); + INSERT INTO chats_contacts VALUES(10,10,0,0);"#, + )?)).await?; + t.sql.run_migrations(&t).await?; + + //std::thread::sleep(std::time::Duration::from_secs(1000)); + let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden) + .await? + .unwrap(); + let email_bob = Contact::get_by_id(&t, email_bob_id).await?; + assert_eq!(email_bob.origin, Origin::Hidden); // Email bob is in no chats, so, contact is hidden + assert_eq!(email_bob.e2ee_avail(&t).await?, false); + assert_eq!(email_bob.fingerprint(), None); + assert_eq!(email_bob.get_verifier_id(&t).await?, None); + + let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?; + let pgp_bob_id = tools::single_value(bob_chat_contacts).unwrap(); + let pgp_bob = Contact::get_by_id(&t, pgp_bob_id).await?; + assert_eq!(pgp_bob.origin, Origin::OutgoingTo); + assert_eq!(pgp_bob.e2ee_avail(&t).await?, true); + assert_eq!( + pgp_bob.fingerprint().unwrap(), + pgp_bob.public_key(&t).await?.unwrap().dc_fingerprint() + ); + assert_eq!(pgp_bob.get_verifier_id(&t).await?, None); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_key_contacts_migration_email1() -> Result<()> { + let t = STOP_MIGRATIONS_AT + .scope(131, async move { TestContext::new_alice().await }) + .await; + + t.sql.call_write(|conn| Ok(conn.execute_batch(r#" + INSERT INTO acpeerstates VALUES(1,'bob@example.net',0,0,NULL,1,1745589039,X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680b932f021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487069707ff53fae1e7d40cf1b2b0ea22c1cadd735b16fbdc4c0571fc88b9c489bb2023fce8e197880c4d579d67fa75124ae696fecc17cd5815362e00601e9240d10e0a46bfc0567b88312a41e56bedb045482de61279eb7d10cf15b23e56dc254084401eeaac0780f7ca912f6f9e3d4e4b3f82b1a0fc3ee6600e5367549dbc83242743dee435287c1ba1db604f4d7416780a5d43fe8047338866715a9081285797b96cb9340822d04331121646188e3c9e9bf209611fe9f72bf5df3f0cfdf46d698566ae5ef75e8fa05f5d760e22e592c61e2a48dffeff8cec2f425a5c04951df78f68362f475ba9a8f15e4f588d85f8738815d92d8ccd876833c1683927dd28f5ede9da8ecec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680b932f021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf704873c3807fd1e3e54a16fc879fc006af060de9216a761188b73fcaa617383feb632b80bcbbf362ea4381bd15e58cffa5ec03da0cd50e4adf37be5c81a66d6a22b9835cbb9c219ecd7426547e6a8ec35839d76795aa448a544bc4a5ecfea0284c1ee576a3dc9fdd41beb54f3f60283451b1d292bddda076e1c02b82d957708dcea5f6fb4faf72f69bdff01ed89468e9870e1a081dac09ccc0b9590ac12e7b85008838e8f9aafcfb2bdcc63085a70819c4f6b8b77cff5716af43c834d114a22745eea504b90c431abadb06ba979021726de29fa09523254ff88d3a9a94ba22c46ba5eb4919ca3c8d1f58b1349c5dd1747afb88067dd2ee258b07b8eb0e09235da2469fcc08c79',NULL,'CCCB5AA9F6E1141C943165F1DB18B18CBCF70487',NULL,NULL,'',NULL,NULL,'',NULL); + INSERT INTO contacts VALUES(10,'','bob@example.net',16384,0,0,'','',1745589041,'',0); + INSERT INTO chats VALUES(10,120,'Group',0,'',0,'','g=1745609548',0,0,0,0,0,1745609547,0,NULL,1); + INSERT INTO chats_contacts VALUES(10,1,1745609547,0); + INSERT INTO chats_contacts VALUES(10,10,1745609547,0);"#, + )?)).await?; + t.sql.run_migrations(&t).await?; + + let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden) + .await? + .unwrap(); + let email_bob = Contact::get_by_id(&t, email_bob_id).await?; + assert_eq!(email_bob.origin, Origin::OutgoingTo); + assert_eq!(email_bob.e2ee_avail(&t).await?, false); + assert_eq!(email_bob.fingerprint(), None); + assert_eq!(email_bob.get_verifier_id(&t).await?, None); + + let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?; + dbg!(&bob_chat_contacts); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_key_contacts_migration_email2() -> Result<()> { + let t = STOP_MIGRATIONS_AT + .scope(131, async move { TestContext::new_alice().await }) + .await; + + t.sql.call_write(|conn| Ok(conn.execute_batch(r#" + INSERT INTO acpeerstates VALUES(1,'bob@example.net',0,0,NULL,20,1745589039,X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680b932f021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487069707ff53fae1e7d40cf1b2b0ea22c1cadd735b16fbdc4c0571fc88b9c489bb2023fce8e197880c4d579d67fa75124ae696fecc17cd5815362e00601e9240d10e0a46bfc0567b88312a41e56bedb045482de61279eb7d10cf15b23e56dc254084401eeaac0780f7ca912f6f9e3d4e4b3f82b1a0fc3ee6600e5367549dbc83242743dee435287c1ba1db604f4d7416780a5d43fe8047338866715a9081285797b96cb9340822d04331121646188e3c9e9bf209611fe9f72bf5df3f0cfdf46d698566ae5ef75e8fa05f5d760e22e592c61e2a48dffeff8cec2f425a5c04951df78f68362f475ba9a8f15e4f588d85f8738815d92d8ccd876833c1683927dd28f5ede9da8ecec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680b932f021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf704873c3807fd1e3e54a16fc879fc006af060de9216a761188b73fcaa617383feb632b80bcbbf362ea4381bd15e58cffa5ec03da0cd50e4adf37be5c81a66d6a22b9835cbb9c219ecd7426547e6a8ec35839d76795aa448a544bc4a5ecfea0284c1ee576a3dc9fdd41beb54f3f60283451b1d292bddda076e1c02b82d957708dcea5f6fb4faf72f69bdff01ed89468e9870e1a081dac09ccc0b9590ac12e7b85008838e8f9aafcfb2bdcc63085a70819c4f6b8b77cff5716af43c834d114a22745eea504b90c431abadb06ba979021726de29fa09523254ff88d3a9a94ba22c46ba5eb4919ca3c8d1f58b1349c5dd1747afb88067dd2ee258b07b8eb0e09235da2469fcc08c79',NULL,'CCCB5AA9F6E1141C943165F1DB18B18CBCF70487',NULL,NULL,'',NULL,NULL,'',NULL); + INSERT INTO contacts VALUES(10,'','bob@example.net',16384,0,0,'','',1745589041,'',0); + INSERT INTO chats VALUES(10,100,'bob@example.net',0,'',0,'','',0,0,0,0,0,1745589039,0,NULL,0); + INSERT INTO chats_contacts VALUES(10,10,0,0);"#, + )?)).await?; + t.sql.run_migrations(&t).await?; + + let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden) + .await? + .unwrap(); + let email_bob = Contact::get_by_id(&t, email_bob_id).await?; + assert_eq!(email_bob.origin, Origin::OutgoingTo); // Email bob is in no chats, so, contact is hidden + assert_eq!(email_bob.e2ee_avail(&t).await?, false); + assert_eq!(email_bob.fingerprint(), None); + assert_eq!(email_bob.get_verifier_id(&t).await?, None); + + let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?; + dbg!(&bob_chat_contacts); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_key_contacts_migration_verified() -> Result<()> { + let t = STOP_MIGRATIONS_AT + .scope(131, async move { TestContext::new_alice().await }) + .await; + + t.sql.call_write(|conn| Ok(conn.execute_batch(r#" + INSERT INTO acpeerstates VALUES(1,'bob@example.net',0,0,X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680be34b021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487921c07ff4f16327797bc5836071b5fcf2ae269c609b697c38b579f2449d0ff07f3e04db07822bfa83a6ca85308d99328c765f9b37b883a3526d38c3c005810ee6d9064acae1c68784781b9688be535a03ed5902d9ab5c9e5d28fb04aa621cb294445b9eab122d86afc0e2a4fd9a6a9af82f50b49295a9852f35c9ed8d816218ba98bc047cfe5fb9432e45ea63140bd16263728b1d1dd18d143b677e1ddd9cb5e939dd51cd7f2c2037cc89b5cee26917ea949e31c808996a5b7efc73636511173f59e2ab025902d86085110ac22988e86e663f19514c559a3b5a52838d1d1fa263f065fddf5fc8c8a1b4dac51aece76d536a3426f133be204dcb03c4a84242137373e39e7cec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680be34b021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf7048798cf07fc09e848aa6595d435805efbbbd0b05bec2fffa88b3b1d6e0a3ba80e300bdd83aa5f03bfcc9361b2a90e9cf8980c775d707467c638a13d65a01eda4b57d3560fada0c675399c263e668a02b84733b2c8e71d7fb9f15cb076f933571d32fc3377bb59ff64da6808eeb96210776126504ae9d6916124d3c679ba810a6c92dfe7d58eba7df22e9f07241d343d3e1792fe48d36fd6ec7d1ed291eae5d5d688872f5c723d5a12c424ff32c25d1348d2b683c5cf9128efb957b0026e607d593528a01dea6458c4709779b8f99bd689ef1bfde7146461317ee2793a130663388977488a9fd1a652377445571b1c913ee14fe0b22d451943b4fe1d0578b71201f1ee106f4c',1,1745609547,X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680be34b021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487921c07ff4f16327797bc5836071b5fcf2ae269c609b697c38b579f2449d0ff07f3e04db07822bfa83a6ca85308d99328c765f9b37b883a3526d38c3c005810ee6d9064acae1c68784781b9688be535a03ed5902d9ab5c9e5d28fb04aa621cb294445b9eab122d86afc0e2a4fd9a6a9af82f50b49295a9852f35c9ed8d816218ba98bc047cfe5fb9432e45ea63140bd16263728b1d1dd18d143b677e1ddd9cb5e939dd51cd7f2c2037cc89b5cee26917ea949e31c808996a5b7efc73636511173f59e2ab025902d86085110ac22988e86e663f19514c559a3b5a52838d1d1fa263f065fddf5fc8c8a1b4dac51aece76d536a3426f133be204dcb03c4a84242137373e39e7cec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680be34b021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf7048798cf07fc09e848aa6595d435805efbbbd0b05bec2fffa88b3b1d6e0a3ba80e300bdd83aa5f03bfcc9361b2a90e9cf8980c775d707467c638a13d65a01eda4b57d3560fada0c675399c263e668a02b84733b2c8e71d7fb9f15cb076f933571d32fc3377bb59ff64da6808eeb96210776126504ae9d6916124d3c679ba810a6c92dfe7d58eba7df22e9f07241d343d3e1792fe48d36fd6ec7d1ed291eae5d5d688872f5c723d5a12c424ff32c25d1348d2b683c5cf9128efb957b0026e607d593528a01dea6458c4709779b8f99bd689ef1bfde7146461317ee2793a130663388977488a9fd1a652377445571b1c913ee14fe0b22d451943b4fe1d0578b71201f1ee106f4c','CCCB5AA9F6E1141C943165F1DB18B18CBCF70487','CCCB5AA9F6E1141C943165F1DB18B18CBCF70487',X'c6c04d045e30c757010800cec0b4bfc4277c88a0d652cc937d5cd66f2f9918a3e96a63d3bdd8f41858277f4075101680e7ffcf8c0cdb2b988a8a8e903449996a0cc93e45cf07225c0084549b44f5eada83b42bf19be1fddd8117a478bf5d639e270f64a210134aa52db113b4a4525e0ef3e2313990ac498762858349005f0aba3065dbe730095b27d26360e9e070c793c5cd23c663ece6cd7bc850bed4e5aee1fc160b250cdf0cb527374a4dc0d6af2ad292f9a015d52a27ba490e4d47153b7ec7db6f4252b7ba7f415e2470bf4bb4cc34ae23c7831ff7512c0e142fd3eaeaf9899816a67b504fb04d4f03b573793489476a28257313ea8d80987f0f3d47d192fdce896ba1ecb339152a470011010001cd113c626f62406578616d706c652e6e65743ec2c08b0410010800350219010502680be34b021b03040b090807061508090a0b02031602010127162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf70487921c07ff4f16327797bc5836071b5fcf2ae269c609b697c38b579f2449d0ff07f3e04db07822bfa83a6ca85308d99328c765f9b37b883a3526d38c3c005810ee6d9064acae1c68784781b9688be535a03ed5902d9ab5c9e5d28fb04aa621cb294445b9eab122d86afc0e2a4fd9a6a9af82f50b49295a9852f35c9ed8d816218ba98bc047cfe5fb9432e45ea63140bd16263728b1d1dd18d143b677e1ddd9cb5e939dd51cd7f2c2037cc89b5cee26917ea949e31c808996a5b7efc73636511173f59e2ab025902d86085110ac22988e86e663f19514c559a3b5a52838d1d1fa263f065fddf5fc8c8a1b4dac51aece76d536a3426f133be204dcb03c4a84242137373e39e7cec04d045e30c757010800a207812db22369e2482375b6a71b2ef9212eb1090957291b1980edab25d5f970598ac638184d244dac0ae66a9287eac3aaab82c438185814539c667010aa219e3d8d1bbe698dfc953e160c51d26defe61ad68885bd9960aeb3a3d5bb637afab9df216d42894c37e5f6a12f2695ff634b32323c2783c499353758316800138370720320754ddd300dd14fa78f278bcab37f219979889cbc9971ef862739a8dada59c8ff2f88f4bb269aa88e808f0771b987d68779a929d58e17290684c4035e582c8124484dc2d344395129434b711583f20ebb71579cb97bbf4850fe35f2bfcf1ec9c7e949f15c6cc1e8b7d56d2784c83c8a125fb0d0fae53649724a899364550011010001c2c0760418010800200502680be34b021b0c162104cccb5aa9f6e1141c943165f1db18b18cbcf70487000a0910db18b18cbcf7048798cf07fc09e848aa6595d435805efbbbd0b05bec2fffa88b3b1d6e0a3ba80e300bdd83aa5f03bfcc9361b2a90e9cf8980c775d707467c638a13d65a01eda4b57d3560fada0c675399c263e668a02b84733b2c8e71d7fb9f15cb076f933571d32fc3377bb59ff64da6808eeb96210776126504ae9d6916124d3c679ba810a6c92dfe7d58eba7df22e9f07241d343d3e1792fe48d36fd6ec7d1ed291eae5d5d688872f5c723d5a12c424ff32c25d1348d2b683c5cf9128efb957b0026e607d593528a01dea6458c4709779b8f99bd689ef1bfde7146461317ee2793a130663388977488a9fd1a652377445571b1c913ee14fe0b22d451943b4fe1d0578b71201f1ee106f4c','CCCB5AA9F6E1141C943165F1DB18B18CBCF70487','',NULL,NULL,'',1); + INSERT INTO contacts VALUES(10,'','bob@example.net',16384,0,0,'','',1745609549,'',0); + INSERT INTO msgs VALUES(10,'29b4af31-1560-4bc8-9b2b-083f2a3d0432@localhost','',0,10,2,2,1745609547,10,13,1,0,'Messages are guaranteed to be end-to-end encrypted from now on.','','S=11',0,0,0,0,NULL,'',NULL,1,0,'',0,0,0,'',0,NULL,0,NULL,0); + INSERT INTO msgs VALUES(11,'411b3fdd-a20c-48c7-b94d-19c04654a1c5@localhost','',0,10,1,0,1745609548,10,26,1,0,'Hello!','',replace('A=1\nc=1','\n',char(10)),0,0,0,0,X'','','411b3fdd-a20c-48c7-b94d-19c04654a1c5@localhost',1,0,'',0,0,0,'Group',0,NULL,1,NULL,0); + INSERT INTO chats VALUES(10,120,'Group',0,'',0,'-PYdPTYhrEl9L_C6osfpEpQu','g=1745609548',0,0,0,0,0,1745609547,0,NULL,1); + INSERT INTO chats_contacts VALUES(10,1,1745609547,0); + INSERT INTO chats_contacts VALUES(10,10,1745609547,0); + "#, + )?)).await?; + t.sql.run_migrations(&t).await?; + + let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden) + .await? + .unwrap(); + let email_bob = Contact::get_by_id(&t, email_bob_id).await?; + dbg!(&email_bob); + assert_eq!(email_bob.origin, Origin::Hidden); // Email bob is in no chats, so, contact is hidden + assert_eq!(email_bob.e2ee_avail(&t).await?, false); + assert_eq!(email_bob.fingerprint(), None); + assert_eq!(email_bob.get_verifier_id(&t).await?, None); + + let mut bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?; + assert_eq!(bob_chat_contacts.len(), 2); + bob_chat_contacts.retain(|c| *c != ContactId::SELF); + assert_eq!(bob_chat_contacts.len(), 1); + let pgp_bob_id = bob_chat_contacts[0]; + + let pgp_bob = Contact::get_by_id(&t, pgp_bob_id).await?; + dbg!(&pgp_bob); + assert_eq!(pgp_bob.origin, Origin::OutgoingTo); + assert_eq!(pgp_bob.e2ee_avail(&t).await?, true); + assert_eq!( + pgp_bob.fingerprint().unwrap(), + pgp_bob.public_key(&t).await?.unwrap().dc_fingerprint() + ); + assert_eq!(pgp_bob.get_verifier_id(&t).await?, Some(None)); + + Ok(()) +} diff --git a/src/sql/sql_tests.rs b/src/sql/sql_tests.rs index 265c9acf1..c99f29c01 100644 --- a/src/sql/sql_tests.rs +++ b/src/sql/sql_tests.rs @@ -179,9 +179,7 @@ async fn test_migration_flags() -> Result<()> { // as migrations::run() was already executed on context creation, // another call should not result in any action needed. // this test catches some bugs where dbversion was forgotten to be persisted. - let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) = - migrations::run(&t, &t.sql).await?; - assert!(!recalc_fingerprints); + let (update_icons, disable_server_delete, recode_avatar) = migrations::run(&t, &t.sql).await?; assert!(!update_icons); assert!(!disable_server_delete); assert!(!recode_avatar); diff --git a/src/stock_str.rs b/src/stock_str.rs index aca982141..ead552d94 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -80,18 +80,9 @@ pub enum StockMessage { #[strum(props(fallback = "Fingerprints"))] FingerPrints = 30, - #[strum(props(fallback = "End-to-end encryption preferred"))] - E2ePreferred = 34, - #[strum(props(fallback = "%1$s verified."))] ContactVerified = 35, - #[strum(props(fallback = "Cannot establish guaranteed end-to-end encryption with %1$s"))] - ContactNotVerified = 36, - - #[strum(props(fallback = "Changed setup for %1$s"))] - ContactSetupChanged = 37, - #[strum(props(fallback = "Archived chats"))] ArchivedChats = 40, @@ -268,9 +259,6 @@ pub enum StockMessage { #[strum(props(fallback = "Not connected"))] NotConnected = 121, - #[strum(props(fallback = "%1$s changed their address from %2$s to %3$s"))] - AeapAddrChanged = 122, - #[strum(props( fallback = "You changed your email address from %1$s to %2$s.\n\nIf you now send a message to a verified group, contacts there will automatically replace the old with your new address.\n\nIt's highly advised to set up your old email provider to forward all emails to your new email address. Otherwise you might miss messages of contacts who did not get your new address yet." ))] @@ -428,11 +416,6 @@ pub enum StockMessage { #[strum(props(fallback = "Establishing guaranteed end-to-end encryption, please wait…"))] SecurejoinWait = 190, - - #[strum(props( - fallback = "The contact must be online to proceed.\n\nThis process will continue automatically in background." - ))] - SecurejoinTakesLonger = 192, } impl StockMessage { @@ -636,29 +619,22 @@ pub(crate) async fn msg_add_member_remote(context: &Context, added_member_addr: /// contacts to combine with the display name. pub(crate) async fn msg_add_member_local( context: &Context, - added_member_addr: &str, + added_member: ContactId, by_contact: ContactId, ) -> String { - let addr = added_member_addr; - let whom = &match Contact::lookup_id_by_addr(context, addr, Origin::Unknown).await { - Ok(Some(contact_id)) => Contact::get_by_id(context, contact_id) - .await - .map(|contact| contact.get_display_name().to_string()) - .unwrap_or_else(|_| addr.to_string()), - _ => addr.to_string(), - }; + let whom = added_member.get_stock_name(context).await; if by_contact == ContactId::UNDEFINED { translated(context, StockMessage::MsgAddMember) .await - .replace1(whom) + .replace1(&whom) } else if by_contact == ContactId::SELF { translated(context, StockMessage::MsgYouAddMember) .await - .replace1(whom) + .replace1(&whom) } else { translated(context, StockMessage::MsgAddMemberBy) .await - .replace1(whom) + .replace1(&whom) .replace2(&by_contact.get_stock_name(context).await) } } @@ -687,29 +663,22 @@ pub(crate) async fn msg_del_member_remote(context: &Context, removed_member_addr /// the contacts to combine with the display name. pub(crate) async fn msg_del_member_local( context: &Context, - removed_member_addr: &str, + removed_member: ContactId, by_contact: ContactId, ) -> String { - let addr = removed_member_addr; - let whom = &match Contact::lookup_id_by_addr(context, addr, Origin::Unknown).await { - Ok(Some(contact_id)) => Contact::get_by_id(context, contact_id) - .await - .map(|contact| contact.get_display_name().to_string()) - .unwrap_or_else(|_| addr.to_string()), - _ => addr.to_string(), - }; + let whom = removed_member.get_stock_name(context).await; if by_contact == ContactId::UNDEFINED { translated(context, StockMessage::MsgDelMember) .await - .replace1(whom) + .replace1(&whom) } else if by_contact == ContactId::SELF { translated(context, StockMessage::MsgYouDelMember) .await - .replace1(whom) + .replace1(&whom) } else { translated(context, StockMessage::MsgDelMemberBy) .await - .replace1(whom) + .replace1(&whom) .replace2(&by_contact.get_stock_name(context).await) } } @@ -792,11 +761,6 @@ pub(crate) async fn msg_grp_img_deleted(context: &Context, by_contact: ContactId } } -/// Stock string: `End-to-end encryption preferred.`. -pub(crate) async fn e2e_preferred(context: &Context) -> String { - translated(context, StockMessage::E2ePreferred).await -} - /// Stock string: `%1$s invited you to join this group. Waiting for the device of %2$s to reply…`. pub(crate) async fn secure_join_started( context: &Context, @@ -824,11 +788,6 @@ pub(crate) async fn securejoin_wait(context: &Context) -> String { translated(context, StockMessage::SecurejoinWait).await } -/// Stock string: `The contact must be online to proceed. This process will continue automatically in background.`. -pub(crate) async fn securejoin_takes_longer(context: &Context) -> String { - translated(context, StockMessage::SecurejoinTakesLonger).await -} - /// Stock string: `Scan to chat with %1$s`. pub(crate) async fn setup_contact_qr_description( context: &Context, @@ -861,21 +820,6 @@ pub(crate) async fn contact_verified(context: &Context, contact: &Contact) -> St .replace1(addr) } -/// Stock string: `Cannot establish guaranteed end-to-end encryption with %1$s`. -pub(crate) async fn contact_not_verified(context: &Context, contact: &Contact) -> String { - let addr = contact.get_display_name(); - translated(context, StockMessage::ContactNotVerified) - .await - .replace1(addr) -} - -/// Stock string: `Changed setup for %1$s`. -pub(crate) async fn contact_setup_changed(context: &Context, contact_addr: &str) -> String { - translated(context, StockMessage::ContactSetupChanged) - .await - .replace1(contact_addr) -} - /// Stock string: `Archived chats`. pub(crate) async fn archived_chats(context: &Context) -> String { translated(context, StockMessage::ArchivedChats).await @@ -1283,20 +1227,6 @@ pub(crate) async fn broadcast_list(context: &Context) -> String { translated(context, StockMessage::BroadcastList).await } -/// Stock string: `%1$s changed their address from %2$s to %3$s`. -pub(crate) async fn aeap_addr_changed( - context: &Context, - contact_name: &str, - old_addr: &str, - new_addr: &str, -) -> String { - translated(context, StockMessage::AeapAddrChanged) - .await - .replace1(contact_name) - .replace2(old_addr) - .replace3(new_addr) -} - /// Stock string: `⚠️ Your email provider %1$s requires end-to-end encryption which is not setup yet. Tap to learn more.`. pub(crate) async fn unencrypted_email(context: &Context, provider: &str) -> String { translated(context, StockMessage::InvalidUnencryptedMail) diff --git a/src/stock_str/stock_str_tests.rs b/src/stock_str/stock_str_tests.rs index 9b3fa018c..3fd678cf5 100644 --- a/src/stock_str/stock_str_tests.rs +++ b/src/stock_str/stock_str_tests.rs @@ -70,20 +70,7 @@ async fn test_stock_system_msg_simple() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_stock_system_msg_add_member_by_me() { let t = TestContext::new().await; - assert_eq!( - msg_add_member_remote(&t, "alice@example.org").await, - "I added member alice@example.org." - ); - assert_eq!( - msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, - "You added member alice@example.org." - ) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_stock_system_msg_add_member_by_me_with_displayname() { - let t = TestContext::new().await; - Contact::create(&t, "Alice", "alice@example.org") + let alice_contact_id = Contact::create(&t, "Alice", "alice@example.org") .await .expect("failed to create contact"); assert_eq!( @@ -91,7 +78,23 @@ async fn test_stock_system_msg_add_member_by_me_with_displayname() { "I added member alice@example.org." ); assert_eq!( - msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, + msg_add_member_local(&t, alice_contact_id, ContactId::SELF).await, + "You added member Alice." + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_system_msg_add_member_by_me_with_displayname() { + let t = TestContext::new().await; + let alice_contact_id = Contact::create(&t, "Alice", "alice@example.org") + .await + .expect("failed to create contact"); + assert_eq!( + msg_add_member_remote(&t, "alice@example.org").await, + "I added member alice@example.org." + ); + assert_eq!( + msg_add_member_local(&t, alice_contact_id, ContactId::SELF).await, "You added member Alice." ); } @@ -99,16 +102,14 @@ async fn test_stock_system_msg_add_member_by_me_with_displayname() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_stock_system_msg_add_member_by_other_with_displayname() { let t = TestContext::new().await; - let contact_id = { - Contact::create(&t, "Alice", "alice@example.org") - .await - .expect("Failed to create contact Alice"); - Contact::create(&t, "Bob", "bob@example.com") - .await - .expect("failed to create bob") - }; + let alice_contact_id = Contact::create(&t, "Alice", "alice@example.org") + .await + .expect("Failed to create contact Alice"); + let bob_contact_id = Contact::create(&t, "Bob", "bob@example.com") + .await + .expect("failed to create bob"); assert_eq!( - msg_add_member_local(&t, "alice@example.org", contact_id,).await, + msg_add_member_local(&t, alice_contact_id, bob_contact_id).await, "Member Alice added by Bob." ); } @@ -133,7 +134,7 @@ async fn test_partial_download_msg_body() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_update_device_chats() { - let t = TestContext::new().await; + let t = TestContext::new_alice().await; t.update_device_chats().await.ok(); let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); assert_eq!(chats.len(), 2); diff --git a/src/test_utils.rs b/src/test_utils.rs index dfa58f367..62521c33f 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -25,18 +25,17 @@ use crate::chat::{ }; use crate::chatlist::Chatlist; use crate::config::Config; -use crate::constants::DC_CHAT_ID_TRASH; -use crate::constants::DC_GCL_NO_SPECIALS; use crate::constants::{Blocked, Chattype}; -use crate::contact::{import_vcard, make_vcard, Contact, ContactId, Modifier, Origin}; +use crate::constants::{DC_CHAT_ID_TRASH, DC_GCL_NO_SPECIALS}; +use crate::contact::{ + import_vcard, make_vcard, mark_contact_id_as_verified, Contact, ContactId, Modifier, Origin, +}; use crate::context::Context; -use crate::e2ee::EncryptHelper; use crate::events::{Event, EventEmitter, EventType, Events}; -use crate::key::{self, DcKey, DcSecretKey}; +use crate::key::{self, self_fingerprint, DcKey, DcSecretKey}; use crate::log::warn; use crate::message::{update_msg_state, Message, MessageState, MsgId, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; -use crate::peerstate::Peerstate; use crate::pgp::KeyPair; use crate::receive_imf::receive_imf; use crate::securejoin::{get_securejoin_qr, join_securejoin}; @@ -628,9 +627,9 @@ impl TestContext { /// Parses a message. /// /// Parsing a message does not run the entire receive pipeline, but is not without - /// side-effects either. E.g. if the message includes autocrypt headers the relevant - /// peerstates will be updated. Later receiving the message using [Self.recv_msg()] is - /// unlikely to be affected as the peerstate would be processed again in exactly the + /// side-effects either. E.g. if the message includes autocrypt headers, + /// gossiped public keys will be saved. Later receiving the message using [Self.recv_msg()] is + /// unlikely to be affected as the message would be processed again in exactly the /// same way. pub(crate) async fn parse_msg(&self, msg: &SentMessage<'_>) -> MimeMessage { MimeMessage::from_bytes(&self.ctx, msg.payload().as_bytes(), None) @@ -723,7 +722,7 @@ impl TestContext { } /// Returns the [`ContactId`] for the other [`TestContext`], creating a contact if necessary. - pub async fn add_or_lookup_email_contact_id(&self, other: &TestContext) -> ContactId { + pub async fn add_or_lookup_address_contact_id(&self, other: &TestContext) -> ContactId { let primary_self_addr = other.ctx.get_primary_self_addr().await.unwrap(); let addr = ContactAddress::new(&primary_self_addr).unwrap(); // MailinglistAddress is the lowest allowed origin, we'd prefer to not modify the @@ -741,9 +740,11 @@ impl TestContext { } /// Returns the [`Contact`] for the other [`TestContext`], creating it if necessary. - pub async fn add_or_lookup_email_contact(&self, other: &TestContext) -> Contact { - let contact_id = self.add_or_lookup_email_contact_id(other).await; - Contact::get_by_id(&self.ctx, contact_id).await.unwrap() + pub async fn add_or_lookup_address_contact(&self, other: &TestContext) -> Contact { + let contact_id = self.add_or_lookup_address_contact_id(other).await; + let contact = Contact::get_by_id(&self.ctx, contact_id).await.unwrap(); + debug_assert_eq!(contact.is_key_contact(), false); + contact } /// Returns the [`ContactId`] for the other [`TestContext`], creating it if necessary. @@ -755,18 +756,38 @@ impl TestContext { } /// Returns the [`Contact`] for the other [`TestContext`], creating it if necessary. + /// + /// This function imports a vCard, so will transfer the public key + /// as a side effect. pub async fn add_or_lookup_contact(&self, other: &TestContext) -> Contact { let contact_id = self.add_or_lookup_contact_id(other).await; Contact::get_by_id(&self.ctx, contact_id).await.unwrap() } - /// Returns 1:1 [`Chat`] with another account. Panics if it doesn't exist. + /// Returns the [`Contact`] for the other [`TestContext`], creating it if necessary. + /// + /// If the contact does not exist yet, a new contact will be created + /// with the correct fingerprint, but without the public key. + pub async fn add_or_lookup_contact_no_key(&self, other: &TestContext) -> Contact { + let primary_self_addr = other.ctx.get_primary_self_addr().await.unwrap(); + let addr = ContactAddress::new(&primary_self_addr).unwrap(); + let fingerprint = self_fingerprint(other).await.unwrap(); + + let (contact_id, _modified) = + Contact::add_or_lookup_ex(self, "", &addr, fingerprint, Origin::MailinglistAddress) + .await + .expect("add_or_lookup"); + Contact::get_by_id(&self.ctx, contact_id).await.unwrap() + } + + /// Returns 1:1 [`Chat`] with another account address-contact. + /// Panics if it doesn't exist. /// May return a blocked chat. /// /// This first creates a contact using the configured details on the other account, then /// gets the 1:1 chat with this contact. - pub async fn get_chat(&self, other: &TestContext) -> Chat { - let contact = self.add_or_lookup_email_contact(other).await; + pub async fn get_email_chat(&self, other: &TestContext) -> Chat { + let contact = self.add_or_lookup_address_contact(other).await; let chat_id = ChatIdBlocked::lookup_by_contact(&self.ctx, contact.id) .await @@ -774,7 +795,28 @@ impl TestContext { .map(|chat_id_blocked| chat_id_blocked.id) .expect( "There is no chat with this contact. \ - Hint: Use create_chat() instead of get_chat() if this is expected.", + Hint: Use create_email_chat() instead of get_email_chat() if this is expected.", + ); + + Chat::load_from_db(&self.ctx, chat_id).await.unwrap() + } + + /// Returns 1:1 [`Chat`] with another account key-contact. + /// Panics if the chat does not exist. + /// + /// This first creates a contact, but does not import the key, + /// so may create a key-contact with a fingerprint + /// but without the key. + pub async fn get_chat(&self, other: &TestContext) -> Chat { + let contact = self.add_or_lookup_contact_id(other).await; + + let chat_id = ChatIdBlocked::lookup_by_contact(&self.ctx, contact) + .await + .unwrap() + .map(|chat_id_blocked| chat_id_blocked.id) + .expect( + "There is no chat with this contact. \ + Hint: Use create_chat() instead of get_chat() if this is expected.", ); Chat::load_from_db(&self.ctx, chat_id).await.unwrap() @@ -786,11 +828,8 @@ impl TestContext { /// and importing it into `self`, /// then creates a 1:1 chat with this contact. pub async fn create_chat(&self, other: &TestContext) -> Chat { - let vcard = make_vcard(other, &[ContactId::SELF]).await.unwrap(); - let contact_ids = import_vcard(self, &vcard).await.unwrap(); - assert_eq!(contact_ids.len(), 1); - let contact_id = contact_ids.first().unwrap(); - let chat_id = ChatId::create_for_contact(self, *contact_id).await.unwrap(); + let contact_id = self.add_or_lookup_contact_id(other).await; + let chat_id = ChatId::create_for_contact(self, contact_id).await.unwrap(); Chat::load_from_db(self, chat_id).await.unwrap() } @@ -799,7 +838,7 @@ impl TestContext { /// /// This function can be used to create unencrypted chats. pub async fn create_email_chat(&self, other: &TestContext) -> Chat { - let contact = self.add_or_lookup_email_contact(other).await; + let contact = self.add_or_lookup_address_contact(other).await; let chat_id = ChatId::create_for_contact(self, contact.id).await.unwrap(); Chat::load_from_db(self, chat_id).await.unwrap() @@ -914,7 +953,11 @@ impl TestContext { "device-talk".to_string() } else if sel_chat.get_type() == Chattype::Single && !members.is_empty() { let contact = Contact::get_by_id(self, members[0]).await.unwrap(); - contact.get_addr().to_string() + if contact.is_key_contact() { + format!("KEY {}", contact.get_addr()) + } else { + contact.get_addr().to_string() + } } else if sel_chat.get_type() == Chattype::Mailinglist && !members.is_empty() { "mailinglist".to_string() } else { @@ -934,7 +977,7 @@ impl TestContext { "" }, match sel_chat.get_profile_image(self).await.unwrap() { - Some(icon) => match icon.to_str() { + Some(icon) => match icon.strip_prefix(self.get_blobdir()).unwrap().to_str() { Some(icon) => format!(" Icon: {icon}"), _ => " Icon: Err".to_string(), }, @@ -982,8 +1025,8 @@ impl TestContext { let chat_id = create_group_chat(self, protect, chat_name).await.unwrap(); let mut to_add = vec![]; for member in members { - let contact = self.add_or_lookup_contact(member).await; - to_add.push(contact.id); + let contact_id = self.add_or_lookup_contact_id(member).await; + to_add.push(contact_id); } add_to_chat_contacts_table(self, time(), chat_id, &to_add) .await @@ -1296,7 +1339,13 @@ pub(crate) async fn get_chat_msg( asserted_msgs_count: usize, ) -> Message { let msgs = chat::get_chat_msgs(&t.ctx, chat_id).await.unwrap(); - assert_eq!(msgs.len(), asserted_msgs_count); + assert_eq!( + msgs.len(), + asserted_msgs_count, + "expected {} messages in a chat but {} found", + asserted_msgs_count, + msgs.len() + ); let msg_id = if let ChatItem::Message { msg_id } = msgs[index] { msg_id } else { @@ -1313,27 +1362,11 @@ fn print_logevent(logevent: &LogEvent) { } /// Saves the other account's public key as verified -/// and peerstate as backwards 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.clone_from(&peerstate.public_key); - peerstate - .verified_key_fingerprint - .clone_from(&peerstate.public_key_fingerprint); - peerstate.backward_verified_key_id = Some(this.get_config_i64(Config::KeyId).await.unwrap()); - - peerstate.save_to_db(&this.sql).await.unwrap(); + let contact_id = this.add_or_lookup_contact_id(other).await; + mark_contact_id_as_verified(this, contact_id, ContactId::SELF) + .await + .unwrap(); } /// Pops a sync message from alice0 and receives it on alice1. Should be used after an action on diff --git a/src/tests/aeap.rs b/src/tests/aeap.rs index a1863690a..b3a5fcc82 100644 --- a/src/tests/aeap.rs +++ b/src/tests/aeap.rs @@ -1,14 +1,19 @@ +//! "AEAP" means "Automatic Email Address Porting" +//! and was the predecessor of key-contacts +//! (i.e. identifying contacts via the fingerprint, +//! while allowing the email address to change). +//! +//! These tests still pass because key-contacts +//! allows messaging to continue after an email address change, +//! just as AEAP did. Some other tests had to be removed. + use anyhow::Result; use crate::chat::{self, Chat, ChatId, ProtectionStatus}; -use crate::contact; -use crate::contact::Contact; -use crate::contact::ContactId; +use crate::contact::{Contact, ContactId}; use crate::message::Message; -use crate::peerstate::Peerstate; use crate::receive_imf::receive_imf; use crate::securejoin::get_securejoin_qr; -use crate::stock_str; use crate::test_utils::mark_as_verified; use crate::test_utils::TestContext; use crate::test_utils::TestContextManager; @@ -35,30 +40,6 @@ async fn test_change_primary_self_addr() -> Result<()> { let alice_bob_chat = alice.create_chat(&bob).await; assert_eq!(alice_msg.chat_id, alice_bob_chat.id); - tcm.section("Bob sends a message to Alice without In-Reply-To"); - // Even if Bob sends a message to Alice without In-Reply-To, - // it's still assigned to the 1:1 chat with Bob and not to - // a group (without secondary addresses, an ad-hoc group - // would be created) - receive_imf( - &alice, - b"From: bob@example.net -To: alice@example.org -Chat-Version: 1.0 -Message-ID: <456@example.com> - -Message w/out In-Reply-To -", - false, - ) - .await?; - - let alice_msg = alice.get_last_msg().await; - - assert_eq!(alice_msg.text, "Message w/out In-Reply-To"); - assert_eq!(alice_msg.get_showpadlock(), false); - assert_eq!(alice_msg.chat_id, alice_bob_chat.id); - Ok(()) } @@ -71,115 +52,67 @@ use ChatForTransition::*; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_aeap_transition_0() { - check_aeap_transition(OneToOne, false, false).await; + check_aeap_transition(OneToOne, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_aeap_transition_1() { - check_aeap_transition(GroupChat, false, false).await; + check_aeap_transition(GroupChat, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_aeap_transition_0_verified() { - check_aeap_transition(OneToOne, true, false).await; + check_aeap_transition(OneToOne, true).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_aeap_transition_1_verified() { - check_aeap_transition(GroupChat, true, false).await; + check_aeap_transition(GroupChat, true).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_aeap_transition_2_verified() { - check_aeap_transition(VerifiedGroup, true, false).await; + check_aeap_transition(VerifiedGroup, true).await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_aeap_transition_0_bob_knew_new_addr() { - check_aeap_transition(OneToOne, false, true).await; -} -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_aeap_transition_1_bob_knew_new_addr() { - check_aeap_transition(GroupChat, false, true).await; -} -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_aeap_transition_0_verified_bob_knew_new_addr() { - check_aeap_transition(OneToOne, true, true).await; -} -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_aeap_transition_1_verified_bob_knew_new_addr() { - check_aeap_transition(GroupChat, true, true).await; -} -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_aeap_transition_2_verified_bob_knew_new_addr() { - check_aeap_transition(VerifiedGroup, true, true).await; -} - -/// Happy path test for AEAP in various configurations. +/// Happy path test for AEAP. /// - `chat_for_transition`: Which chat the transition message should be sent in /// - `verified`: Whether Alice and Bob verified each other -/// - `bob_knew_new_addr`: Whether Bob already had a chat with Alice's new address -async fn check_aeap_transition( - chat_for_transition: ChatForTransition, - verified: bool, - bob_knew_new_addr: bool, -) { - // Alice's new address is "fiona@example.net" so that we can test - // the case where Bob already had contact with Alice's new address - const ALICE_NEW_ADDR: &str = "fiona@example.net"; +async fn check_aeap_transition(chat_for_transition: ChatForTransition, verified: bool) { + const ALICE_NEW_ADDR: &str = "alice2@example.net"; let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; - if bob_knew_new_addr { - let fiona = tcm.fiona().await; - - tcm.send_recv_accept(&fiona, &bob, "Hi").await; - tcm.send_recv(&bob, &fiona, "Hi back").await; - } - - tcm.send_recv_accept(&alice, &bob, "Hi").await; - tcm.send_recv(&bob, &alice, "Hi back").await; + tcm.send_recv_accept(alice, bob, "Hi").await; + tcm.send_recv(bob, alice, "Hi back").await; if verified { - mark_as_verified(&alice, &bob).await; - mark_as_verified(&bob, &alice).await; + mark_as_verified(alice, bob).await; + mark_as_verified(bob, alice).await; } let mut groups = vec![ - chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 0") + chat::create_group_chat(bob, chat::ProtectionStatus::Unprotected, "Group 0") .await .unwrap(), - chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 1") + chat::create_group_chat(bob, chat::ProtectionStatus::Unprotected, "Group 1") .await .unwrap(), ]; if verified { groups.push( - chat::create_group_chat(&bob, chat::ProtectionStatus::Protected, "Group 2") + chat::create_group_chat(bob, chat::ProtectionStatus::Protected, "Group 2") .await .unwrap(), ); groups.push( - chat::create_group_chat(&bob, chat::ProtectionStatus::Protected, "Group 3") + chat::create_group_chat(bob, chat::ProtectionStatus::Protected, "Group 3") .await .unwrap(), ); } - let old_contact = Contact::create(&bob, "Alice", "alice@example.org") - .await - .unwrap(); + let alice_contact = bob.add_or_lookup_contact_id(alice).await; for group in &groups { - chat::add_contact_to_chat(&bob, *group, old_contact) - .await - .unwrap(); - } - - // Already add the new contact to one of the groups. - // We can then later check that the contact isn't in the group twice. - let already_new_contact = Contact::create(&bob, "Alice", ALICE_NEW_ADDR) - .await - .unwrap(); - if verified { - chat::add_contact_to_chat(&bob, groups[2], already_new_contact) + chat::add_contact_to_chat(bob, *group, alice_contact) .await .unwrap(); } @@ -197,12 +130,12 @@ async fn check_aeap_transition( group3_alice = Some(alice.recv_msg(&sent).await.chat_id); } - tcm.change_addr(&alice, ALICE_NEW_ADDR).await; + tcm.change_addr(alice, ALICE_NEW_ADDR).await; tcm.section("Alice sends another message to Bob, this time from her new addr"); // No matter which chat Alice sends to, the transition should be done in all groups let chat_to_send = match chat_for_transition { - OneToOne => alice.create_chat(&bob).await.id, + OneToOne => alice.create_chat(bob).await.id, GroupChat => group1_alice, VerifiedGroup => group3_alice.expect("No verified group"), }; @@ -210,147 +143,50 @@ async fn check_aeap_transition( .send_text(chat_to_send, "Hello from my new addr!") .await; let recvd = bob.recv_msg(&sent).await; - let sent_timestamp = recvd.timestamp_sent; assert_eq!(recvd.text, "Hello from my new addr!"); tcm.section("Check that the AEAP transition worked"); - check_that_transition_worked( - &groups[2..], - &alice, - "alice@example.org", - ALICE_NEW_ADDR, - "Alice", - &bob, - ) - .await; - check_no_transition_done(&groups[0..2], "alice@example.org", &bob).await; - - // Assert that the autocrypt header is also applied to the peerstate - // if the address changed - let bob_alice_peerstate = Peerstate::from_addr(&bob, ALICE_NEW_ADDR) - .await - .unwrap() - .unwrap(); - assert_eq!(bob_alice_peerstate.last_seen, sent_timestamp); - assert_eq!(bob_alice_peerstate.last_seen_autocrypt, sent_timestamp); + check_that_transition_worked(bob, &groups, alice_contact, ALICE_NEW_ADDR).await; tcm.section("Test switching back"); - tcm.change_addr(&alice, "alice@example.org").await; + tcm.change_addr(alice, "alice@example.org").await; let sent = alice .send_text(chat_to_send, "Hello from my old addr!") .await; let recvd = bob.recv_msg(&sent).await; assert_eq!(recvd.text, "Hello from my old addr!"); - check_that_transition_worked( - &groups[2..], - &alice, - // Note that "alice@example.org" and ALICE_NEW_ADDR are switched now: - ALICE_NEW_ADDR, - "alice@example.org", - "Alice", - &bob, - ) - .await; + check_that_transition_worked(bob, &groups, alice_contact, "alice@example.org").await; } async fn check_that_transition_worked( - groups: &[ChatId], - alice: &TestContext, - old_alice_addr: &str, - new_alice_addr: &str, - name: &str, bob: &TestContext, + groups: &[ChatId], + alice_contact_id: ContactId, + alice_addr: &str, ) { - let new_contact = Contact::lookup_id_by_addr(bob, new_alice_addr, contact::Origin::Unknown) - .await - .unwrap() - .unwrap(); - for group in groups { let members = chat::get_chat_contacts(bob, *group).await.unwrap(); - // In all the groups, exactly Bob and Alice's new number are members. - // (and Alice's new number isn't in there twice) + // In all the groups, exactly Bob and Alice are members. assert_eq!( members.len(), 2, "Group {} has members {:?}, but should have members {:?} and {:?}", group, &members, - new_contact, + alice_contact_id, ContactId::SELF ); assert!( - members.contains(&new_contact), - "Group {group} lacks {new_contact}" + members.contains(&alice_contact_id), + "Group {group} lacks {alice_contact_id}" ); assert!(members.contains(&ContactId::SELF)); - - let info_msg = get_last_info_msg(bob, *group).await.unwrap(); - let expected_text = - stock_str::aeap_addr_changed(bob, name, old_alice_addr, new_alice_addr).await; - assert_eq!(info_msg.text, expected_text); - assert_eq!(info_msg.from_id, ContactId::INFO); - - let msg = format!("Sending to group {group}"); - let sent = bob.send_text(*group, &msg).await; - let recvd = alice.recv_msg(&sent).await; - assert_eq!(recvd.text, msg); } -} -async fn check_no_transition_done(groups: &[ChatId], old_alice_addr: &str, bob: &TestContext) { - let old_contact = Contact::lookup_id_by_addr(bob, old_alice_addr, contact::Origin::Unknown) - .await - .unwrap() - .unwrap(); - - for group in groups { - let members = chat::get_chat_contacts(bob, *group).await.unwrap(); - // In all the groups, exactly Bob and Alice's _old_ number are members. - assert_eq!( - members.len(), - 2, - "Group {} has members {:?}, but should have members {:?} and {:?}", - group, - &members, - old_contact, - ContactId::SELF - ); - assert!(members.contains(&old_contact)); - assert!(members.contains(&ContactId::SELF)); - - let last_info_msg = get_last_info_msg(bob, *group).await; - assert!( - last_info_msg.is_none(), - "{last_info_msg:?} shouldn't be there (or it's an unrelated info msg)" - ); - - let sent = bob.send_text(*group, "hi").await; - let msg = Message::load_from_db(bob, sent.sender_msg_id) - .await - .unwrap(); - assert_eq!(msg.get_showpadlock(), true); - } -} - -async fn get_last_info_msg(t: &TestContext, chat_id: ChatId) -> Option { - let msgs = chat::get_chat_msgs_ex( - &t.ctx, - chat_id, - chat::MessageListOptions { - info_only: true, - add_daymarker: false, - }, - ) - .await - .unwrap(); - let msg_id = if let chat::ChatItem::Message { msg_id } = msgs.last()? { - msg_id - } else { - return None; - }; - Some(Message::load_from_db(&t.ctx, *msg_id).await.unwrap()) + // Test that the email address of Alice is updated. + let alice_contact = Contact::get_by_id(bob, alice_contact_id).await.unwrap(); + assert_eq!(alice_contact.get_addr(), alice_addr); } /// Test that an attacker - here Fiona - can't replay a message sent by Alice @@ -360,6 +196,7 @@ async fn test_aeap_replay_attack() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; + let fiona = tcm.fiona().await; tcm.send_recv_accept(&alice, &bob, "Hi").await; tcm.send_recv(&bob, &alice, "Hi back").await; @@ -367,7 +204,8 @@ async fn test_aeap_replay_attack() -> Result<()> { let group = chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 0").await?; - let bob_alice_contact = Contact::create(&bob, "Alice", "alice@example.org").await?; + let bob_alice_contact = bob.add_or_lookup_contact_id(&alice).await; + let bob_fiona_contact = bob.add_or_lookup_contact_id(&fiona).await; chat::add_contact_to_chat(&bob, group, bob_alice_contact).await?; // Alice sends a message which Bob doesn't receive or something @@ -389,17 +227,22 @@ async fn test_aeap_replay_attack() -> Result<()> { // Check that no transition was done assert!(chat::is_contact_in_chat(&bob, group, bob_alice_contact).await?); - let bob_fiona_contact = Contact::create(&bob, "", "fiona@example.net").await?; assert!(!chat::is_contact_in_chat(&bob, group, bob_fiona_contact).await?); Ok(()) } +/// Tests that writing to a contact is possible +/// after address change. +/// +/// This test is redundant after introduction +/// of key-contacts, but is kept to avoid deleting the tests. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_write_to_alice_after_aeap() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; + let alice_grp_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; let qr = get_securejoin_qr(alice, Some(alice_grp_id)).await?; tcm.exec_securejoin_qr(bob, alice, &qr).await; @@ -415,15 +258,13 @@ async fn test_write_to_alice_after_aeap() -> Result<()> { let sent = alice.send_text(alice_grp_id, "Hello!").await; bob.recv_msg(&sent).await; - assert!(!bob_alice_contact.is_verified(bob).await?); + assert!(bob_alice_contact.is_verified(bob).await?); let bob_alice_chat = Chat::load_from_db(bob, bob_alice_chat.id).await?; assert!(bob_alice_chat.is_protected()); let mut msg = Message::new_text("hi".to_string()); - assert!(chat::send_msg(bob, bob_alice_chat.id, &mut msg) - .await - .is_err()); + chat::send_msg(bob, bob_alice_chat.id, &mut msg).await?; - // But encrypted communication is still possible in unprotected groups with old Alice. + // Encrypted communication is also possible in unprotected groups with Alice. let sent = bob .send_text(bob_unprotected_grp_id, "Alice, how is your address change?") .await; diff --git a/src/tests/verified_chats.rs b/src/tests/verified_chats.rs index d53c2610a..2bead578c 100644 --- a/src/tests/verified_chats.rs +++ b/src/tests/verified_chats.rs @@ -5,10 +5,10 @@ use crate::chat::resend_msgs; use crate::chat::{ self, add_contact_to_chat, remove_contact_from_chat, send_msg, Chat, ProtectionStatus, }; -use crate::chatlist::Chatlist; use crate::config::Config; -use crate::constants::{Chattype, DC_GCL_FOR_FORWARDING}; -use crate::contact::{Contact, ContactId, Origin}; +use crate::constants::Chattype; +use crate::contact::{Contact, ContactId}; +use crate::key::{load_self_public_key, DcKey}; use crate::message::{Message, Viewtype}; use crate::mimefactory::MimeFactory; use crate::mimeparser::SystemMessage; @@ -20,16 +20,16 @@ use crate::tools::SystemTime; 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; +async fn test_verified_oneonone_chat_not_broken_by_classical() { + check_verified_oneonone_chat_protection_not_broken(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 test_verified_oneonone_chat_not_broken_by_device_change() { + check_verified_oneonone_chat_protection_not_broken(false).await; } -async fn check_verified_oneonone_chat(broken_by_classical_email: bool) { +async fn check_verified_oneonone_chat_protection_not_broken(broken_by_classical_email: bool) { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; @@ -59,19 +59,19 @@ async fn check_verified_oneonone_chat(broken_by_classical_email: bool) { // Bob's contact is still verified, but the chat isn't marked as protected anymore let contact = alice.add_or_lookup_contact(&bob).await; assert_eq!(contact.is_verified(&alice).await.unwrap(), true); - assert_verified(&alice, &bob, ProtectionStatus::ProtectionBroken).await; + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; } else { tcm.section("Bob sets up another Delta Chat device"); - let bob2 = TestContext::new().await; + let bob2 = tcm.unconfigured().await; bob2.set_name("bob2"); bob2.configure_addr("bob@example.net").await; SystemTime::shift(std::time::Duration::from_secs(3600)); tcm.send_recv(&bob2, &alice, "Using another device now") .await; - let contact = alice.add_or_lookup_contact(&bob).await; + let contact = alice.add_or_lookup_contact(&bob2).await; assert_eq!(contact.is_verified(&alice).await.unwrap(), false); - assert_verified(&alice, &bob, ProtectionStatus::ProtectionBroken).await; + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; } tcm.section("Bob sends another message from DC"); @@ -157,44 +157,42 @@ async fn test_create_verified_oneonone_chat() -> Result<()> { tcm.send_recv(&fiona_new, &alice, "I have a new device") .await; - // The chat should be and stay unprotected + // Alice gets a new unprotected chat with new Fiona contact. { let chat = alice.get_chat(&fiona_new).await; assert!(!chat.is_protected()); - assert!(chat.is_protection_broken()); - let msg1 = get_chat_msg(&alice, chat.id, 0, 3).await; - assert_eq!(msg1.get_info_type(), SystemMessage::ChatProtectionEnabled); - - let msg2 = get_chat_msg(&alice, chat.id, 1, 3).await; - assert_eq!(msg2.get_info_type(), SystemMessage::ChatProtectionDisabled); - - let msg2 = get_chat_msg(&alice, chat.id, 2, 3).await; - assert_eq!(msg2.text, "I have a new device"); + let msg = get_chat_msg(&alice, chat.id, 0, 1).await; + assert_eq!(msg.text, "I have a new device"); // 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_missing_peerstate_reexecute_securejoin() -> Result<()> { +async fn test_missing_key_reexecute_securejoin() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; - let alice_addr = alice.get_config(Config::Addr).await?.unwrap(); let bob = &tcm.bob().await; enable_verified_oneonone_chats(&[alice, bob]).await; let chat_id = tcm.execute_securejoin(bob, alice).await; let chat = Chat::load_from_db(bob, chat_id).await?; assert!(chat.is_protected()); bob.sql - .execute("DELETE FROM acpeerstates WHERE addr=?", (&alice_addr,)) + .execute( + "DELETE FROM public_keys WHERE fingerprint=?", + (&load_self_public_key(alice) + .await + .unwrap() + .dc_fingerprint() + .hex(),), + ) .await?; let chat_id = tcm.execute_securejoin(bob, alice).await; let chat = Chat::load_from_db(bob, chat_id).await?; @@ -242,6 +240,10 @@ async fn test_create_unverified_oneonone_chat() -> Result<()> { Ok(()) } +/// Tests that receiving unencrypted message +/// does not disable protection of 1:1 chat. +/// +/// Instead, an email-chat is created. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_degrade_verified_oneonone_chat() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -265,211 +267,16 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> { ) .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 msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).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] { - SystemTime::shift(std::time::Duration::from_secs(300)); - // 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; - 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; - 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; - assert!(chat.is_protected()); - assert!(!chat.is_protection_broken()); - } - - alice - .golden_test_chat(chat.id, "test_verified_oneonone_chat_enable_disable") - .await; - - Ok(()) -} - -/// Messages with old timestamps are difficult for verified chats: -/// - They must not be sorted over a protection-changed info message. -/// That's what `test_old_message_2` tests -/// - If they change the protection, then they must not be sorted over existing other messages, -/// because then the protection-changed info message would also be above these existing messages. -/// That's what `test_old_message_3` tests. -/// -/// `test_old_message_1` tests the case where both the old and the new message -/// change verification -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_old_message_1() -> 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 chat = alice.create_chat(&bob).await; // This creates a protection-changed info message - assert!(chat.is_protected()); - - // This creates protection-changed info message #2; - // even though the date is old, info message and email must be sorted below the original info message. - receive_imf( - &alice, - b"From: Bob \n\ - To: alice@example.org\n\ - Message-ID: <1234-2-3@example.org>\n\ - Date: Sat, 07 Dec 2019 19:00:27 +0000\n\ - \n\ - Message from Thunderbird\n", - true, - ) - .await?; - - alice.golden_test_chat(chat.id, "test_old_message_1").await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_old_message_2() -> 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; - - // This creates protection-changed info message #1: - let chat = alice.create_chat(&bob).await; - assert!(chat.is_protected()); - let protection_msg = alice.get_last_msg().await; - assert_eq!( - protection_msg.param.get_cmd(), - SystemMessage::ChatProtectionEnabled - ); - - // This creates protection-changed info message #2 with `timestamp_sort` greater by 1. - let first_email = receive_imf( - &alice, - b"From: Bob \n\ - To: alice@example.org\n\ - Message-ID: <1234-2-3@example.org>\n\ - Date: Sun, 08 Dec 2019 19:00:27 +0000\n\ - \n\ - Somewhat old message\n", - false, - ) - .await? - .unwrap(); - - // Both messages will get the same timestamp, so this one will be sorted under the previous one - // even though it has an older timestamp. - let second_email = receive_imf( - &alice, - b"From: Bob \n\ - To: alice@example.org\n\ - Message-ID: <2319-2-3@example.org>\n\ - Date: Sat, 07 Dec 2019 19:00:27 +0000\n\ - \n\ - Even older message, that must NOT be shown before the info message\n", - true, - ) - .await? - .unwrap(); - - assert_eq!(first_email.sort_timestamp, second_email.sort_timestamp); - assert_eq!( - first_email.sort_timestamp, - protection_msg.timestamp_sort + 1 - ); - - alice.golden_test_chat(chat.id, "test_old_message_2").await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_old_message_3() -> 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 my verified device!") - .await; - - // This unverified message must not be sorted over the message sent in the previous line: - receive_imf( - &alice, - b"From: Bob \n\ - To: alice@example.org\n\ - Message-ID: <1234-2-3@example.org>\n\ - Date: Sat, 07 Dec 2019 19:00:27 +0000\n\ - \n\ - Old, unverified message\n", - true, - ) - .await?; - - alice - .golden_test_chat(alice.get_chat(&bob).await.id, "test_old_message_3") - .await; + let email_chat = alice.get_email_chat(&bob).await; + assert!(!email_chat.is_encrypted(&alice).await?); + let email_msg = get_chat_msg(&alice, email_chat.id, 0, 1).await; + assert_eq!(email_msg.text, "hello".to_string()); + assert!(!email_msg.is_system_message()); Ok(()) } @@ -602,13 +409,35 @@ async fn test_outgoing_mua_msg() -> Result<()> { .unwrap(); tcm.send_recv(&alice, &bob, "Sending with DC again").await; + // Unencrypted message from MUA gets into a separate chat. + // PGP chat gets all encrypted messages. alice .golden_test_chat(sent.chat_id, "test_outgoing_mua_msg") .await; + alice + .golden_test_chat(alice.get_chat(&bob).await.id, "test_outgoing_mua_msg_pgp") + .await; Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_outgoing_encrypted_msg() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + enable_verified_oneonone_chats(&[alice]).await; + + mark_as_verified(alice, bob).await; + let chat_id = alice.create_chat(bob).await.id; + let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml"); + receive_imf(alice, raw, false).await?; + alice + .golden_test_chat(chat_id, "test_outgoing_encrypted_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 @@ -652,96 +481,23 @@ async fn test_reply() -> Result<()> { 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); - } + assert_ne!(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; - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; - let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?; - assert_eq!(chats.len(), 1); - - 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; - - let contact = alice.add_or_lookup_contact(&bob_new).await; - assert_eq!( - contact.is_verified(&alice).await.unwrap(), - // Bob sent a message with a new key, so he most likely doesn't have - // the old key anymore. This means that Alice's device should show - // him as unverified: - false - ); - let chat = alice.get_chat(&bob_new).await; - assert_eq!(chat.is_protected(), false); - assert_eq!(chat.is_protection_broken(), true); - let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?; - assert_eq!(chats.len(), 1); - - { - let alice_bob_chat = alice.get_chat(&bob_new).await; - assert!(!alice_bob_chat.can_send(&alice).await?); - - // Alice's UI should still be able to save a draft, which Alice started to type right when she got Bob's message: - let mut msg = Message::new_text("Draftttt".to_string()); - alice_bob_chat.id.set_draft(&alice, Some(&mut msg)).await?; - assert_eq!( - alice_bob_chat.id.get_draft(&alice).await?.unwrap().text, - "Draftttt" - ); - } - - tcm.execute_securejoin(&alice, &bob_new).await; - assert_verified(&alice, &bob_new, ProtectionStatus::Protected).await; - - Ok(()) -} - +/// Tests that message from old DC setup does not break +/// new verified chat. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_message_from_old_dc_setup() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob_old = &tcm.unconfigured().await; + enable_verified_oneonone_chats(&[alice, bob_old]).await; - mark_as_verified(bob_old, alice).await; bob_old.configure_addr("bob@example.net").await; + mark_as_verified(bob_old, alice).await; let chat = bob_old.create_chat(alice).await; let sent_old = bob_old .send_text(chat.id, "Soon i'll have a new device") @@ -759,22 +515,15 @@ async fn test_message_from_old_dc_setup() -> Result<()> { assert_verified(alice, bob, ProtectionStatus::Protected).await; let msg = alice.recv_msg(&sent_old).await; - assert!(!msg.get_showpadlock()); + assert!(msg.get_showpadlock()); let contact = alice.add_or_lookup_contact(bob).await; - // The outdated Bob's Autocrypt header isn't applied, so the verification preserves. + + // The outdated Bob's Autocrypt header isn't applied + // and the message goes to another chat, so the verification preserves. assert!(contact.is_verified(alice).await.unwrap()); let chat = alice.get_chat(bob).await; assert!(chat.is_protected()); assert_eq!(chat.is_protection_broken(), false); - let protection_msg = alice.get_last_msg().await; - assert_eq!( - protection_msg.param.get_cmd(), - SystemMessage::ChatProtectionEnabled - ); - assert!(protection_msg.timestamp_sort >= msg.timestamp_rcvd); - alice - .golden_test_chat(msg.chat_id, "verified_chats_message_from_old_dc_setup") - .await; Ok(()) } @@ -811,43 +560,6 @@ async fn test_verify_then_verify_again() -> Result<()> { Ok(()) } -/// Regression test: -/// - Verify a contact -/// - The contact stops using DC and sends a message from a classical MUA instead -/// - Delete the 1:1 chat -/// - Create a 1:1 chat -/// - Check that the created chat is not marked as protected -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_create_oneonone_chat_with_former_verified_contact() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice]).await; - - mark_as_verified(&alice, &bob).await; - - receive_imf( - &alice, - b"Subject: Message from bob\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(); - - alice.create_chat(&bob).await; - - assert_verified(&alice, &bob, ProtectionStatus::Unprotected).await; - - Ok(()) -} - /// Tests that on the second device of a protected group creator the first message is /// `SystemMessage::ChatProtectionEnabled` and the second one is the message populating the group. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -893,7 +605,7 @@ async fn test_verified_member_added_reordering() -> Result<()> { let fiona = &tcm.fiona().await; enable_verified_oneonone_chats(&[alice, bob, fiona]).await; - let alice_fiona_contact_id = Contact::create(alice, "Fiona", "fiona@example.net").await?; + let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; // Bob and Fiona scan Alice's QR code. tcm.execute_securejoin(bob, alice).await; diff --git a/src/update_helper.rs b/src/update_helper.rs index 2ba035c7d..6d0b25f17 100644 --- a/src/update_helper.rs +++ b/src/update_helper.rs @@ -153,104 +153,4 @@ mod tests { Ok(()) } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_out_of_order_group_name() -> Result<()> { - let t = TestContext::new_alice().await; - - receive_imf( - &t, - b"From: Bob Authname \n\ - To: alice@example.org\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: abcde123456\n\ - Chat-Group-Name: initial name\n\ - Date: Sun, 22 Mar 2021 01:00:00 +0000\n\ - \n\ - first message\n", - false, - ) - .await?; - let msg = t.get_last_msg().await; - let chat = Chat::load_from_db(&t, msg.chat_id).await?; - assert_eq!(chat.name, "initial name"); - - receive_imf( - &t, - b"From: Bob Authname \n\ - To: alice@example.org\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: abcde123456\n\ - Chat-Group-Name: =?utf-8?q?another=0Aname update?=\n\ - Chat-Group-Name-Changed: =?utf-8?q?a=0Aname update?=\n\ - Date: Sun, 22 Mar 2021 03:00:00 +0000\n\ - \n\ - third message\n", - false, - ) - .await?; - receive_imf( - &t, - b"From: Bob Authname \n\ - To: alice@example.org\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: abcde123456\n\ - Chat-Group-Name: =?utf-8?q?a=0Aname update?=\n\ - Chat-Group-Name-Changed: initial name\n\ - Date: Sun, 22 Mar 2021 02:00:00 +0000\n\ - \n\ - second message\n", - false, - ) - .await?; - let msg = t.get_last_msg().await; - let chat = Chat::load_from_db(&t, msg.chat_id).await?; - assert_eq!(chat.name, "another name update"); - - // Assert that the \n was correctly removed from the group name also in the system message - assert_eq!(msg.text.contains('\n'), false); - - // This doesn't update the name because Date is the same and name is greater. - receive_imf( - &t, - b"From: Bob Authname \n\ - To: alice@example.org\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: abcde123456\n\ - Chat-Group-Name: another name update 4\n\ - Chat-Group-Name-Changed: another name update\n\ - Date: Sun, 22 Mar 2021 03:00:00 +0000\n\ - \n\ - 4th message\n", - false, - ) - .await?; - let chat = Chat::load_from_db(&t, chat.id).await?; - assert_eq!(chat.name, "another name update"); - - // This updates the name because Date is the same and name is lower. - receive_imf( - &t, - b"From: Bob Authname \n\ - To: alice@example.org\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Chat-Group-ID: abcde123456\n\ - Chat-Group-Name: another name updat\n\ - Chat-Group-Name-Changed: another name update\n\ - Date: Sun, 22 Mar 2021 03:00:00 +0000\n\ - \n\ - 5th message\n", - false, - ) - .await?; - let chat = Chat::load_from_db(&t, chat.id).await?; - assert_eq!(chat.name, "another name updat"); - - Ok(()) - } } diff --git a/src/webxdc.rs b/src/webxdc.rs index eac8adfaa..678e3dce4 100644 --- a/src/webxdc.rs +++ b/src/webxdc.rs @@ -39,7 +39,7 @@ use crate::constants::Chattype; use crate::contact::ContactId; use crate::context::Context; use crate::events::EventType; -use crate::key::{load_self_public_key, DcKey}; +use crate::key::self_fingerprint; use crate::log::{info, warn}; use crate::message::{Message, MessageState, MsgId, Viewtype}; use crate::mimefactory::RECOMMENDED_FILE_SIZE; @@ -962,7 +962,7 @@ impl Message { } async fn get_webxdc_self_addr(&self, context: &Context) -> Result { - let fingerprint = load_self_public_key(context).await?.dc_fingerprint().hex(); + let fingerprint = self_fingerprint(context).await?; let data = format!("{}-{}", fingerprint, self.rfc724_mid); let hash = Sha256::digest(data.as_bytes()); Ok(format!("{hash:x}")) diff --git a/src/webxdc/webxdc_tests.rs b/src/webxdc/webxdc_tests.rs index 464e22f99..38b5e2e08 100644 --- a/src/webxdc/webxdc_tests.rs +++ b/src/webxdc/webxdc_tests.rs @@ -10,7 +10,6 @@ use crate::chat::{ }; use crate::chatlist::Chatlist; use crate::config::Config; -use crate::contact::Contact; use crate::download::DownloadState; use crate::ephemeral; use crate::receive_imf::{receive_imf, receive_imf_from_inbox}; @@ -1601,58 +1600,6 @@ async fn test_webxdc_info_msg_no_cleanup_on_interrupted_series() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_webxdc_opportunistic_encryption() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - - // Bob sends sth. to Alice, Alice has Bob's key - let bob_chat_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "chat").await?; - add_contact_to_chat( - &bob, - bob_chat_id, - Contact::create(&bob, "", "alice@example.org").await?, - ) - .await?; - send_text_msg(&bob, bob_chat_id, "populate".to_string()).await?; - alice.recv_msg(&bob.pop_sent_msg().await).await; - - // Alice sends instance+update to Bob - let alice_chat_id = alice.get_last_msg().await.chat_id; - alice_chat_id.accept(&alice).await?; - let alice_instance = send_webxdc_instance(&alice, alice_chat_id).await?; - let sent1 = &alice.pop_sent_msg().await; - alice - .send_webxdc_status_update(alice_instance.id, r#"{"payload":42}"#) - .await?; - alice.flush_status_updates().await?; - let sent2 = &alice.pop_sent_msg().await; - let update_msg = sent2.load_from_db().await; - assert!(alice_instance.get_showpadlock()); - assert!(update_msg.get_showpadlock()); - - // Bob receives instance+update - let bob_instance = bob.recv_msg(sent1).await; - bob.recv_msg_trash(sent2).await; - assert!(bob_instance.get_showpadlock()); - - // Bob adds Claire with unknown key, update to Alice+Claire cannot be encrypted - add_contact_to_chat( - &bob, - bob_chat_id, - Contact::create(&bob, "", "claire@example.org").await?, - ) - .await?; - bob.send_webxdc_status_update(bob_instance.id, r#"{"payload":43}"#) - .await?; - bob.flush_status_updates().await?; - let sent3 = bob.pop_sent_msg().await; - let update_msg = sent3.load_from_db().await; - assert!(!update_msg.get_showpadlock()); - - Ok(()) -} - // check that `info.internet_access` is not set for normal, non-integrated webxdc - // even if they use the deprecated option `request_internet_access` in manifest.toml #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/test-data/golden/receive_imf_older_message_from_2nd_device b/test-data/golden/receive_imf_older_message_from_2nd_device index 13fe53ba4..abc38ad33 100644 --- a/test-data/golden/receive_imf_older_message_from_2nd_device +++ b/test-data/golden/receive_imf_older_message_from_2nd_device @@ -1,4 +1,4 @@ -Single#Chat#10: bob@example.net [bob@example.net] +Single#Chat#10: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- Msg#10: Me (Contact#Contact#Self): We share this account √ Msg#11: Me (Contact#Contact#Self): I'm Alice too √ diff --git a/test-data/golden/test_old_message_1 b/test-data/golden/test_old_message_1 index 61e54d298..d18664daf 100644 --- a/test-data/golden/test_old_message_1 +++ b/test-data/golden/test_old_message_1 @@ -1,4 +1,4 @@ -Single#Chat#10: Bob [bob@example.net] +Single#Chat#10: Bob [PGP 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 🛡️❌] diff --git a/test-data/golden/test_old_message_2 b/test-data/golden/test_old_message_2 index d7e7712ab..842ddc8b2 100644 --- a/test-data/golden/test_old_message_2 +++ b/test-data/golden/test_old_message_2 @@ -1,4 +1,4 @@ -Single#Chat#10: Bob [bob@example.net] +Single#Chat#10: Bob [PGP 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 🛡️❌] diff --git a/test-data/golden/test_old_message_3 b/test-data/golden/test_old_message_3 index 82630528a..858c3e525 100644 --- a/test-data/golden/test_old_message_3 +++ b/test-data/golden/test_old_message_3 @@ -1,4 +1,4 @@ -Single#Chat#10: Bob [bob@example.net] 🛡️ +Single#Chat#10: Bob [PGP 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 my verified device! [FRESH] diff --git a/test-data/golden/test_old_message_5 b/test-data/golden/test_old_message_5 index 75d3c0af0..624838a43 100644 --- a/test-data/golden/test_old_message_5 +++ b/test-data/golden/test_old_message_5 @@ -1,4 +1,4 @@ -Single#Chat#10: Bob [bob@example.net] +Single#Chat#10: Bob [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- Msg#10: Me (Contact#Contact#Self): Happy birthday, Bob! √ Msg#11: (Contact#Contact#10): Happy birthday to me, Alice! [FRESH] diff --git a/test-data/golden/test_outgoing_encrypted_msg b/test-data/golden/test_outgoing_encrypted_msg new file mode 100644 index 000000000..cd3b205be --- /dev/null +++ b/test-data/golden/test_outgoing_encrypted_msg @@ -0,0 +1,5 @@ +Single#Chat#10: bob@example.net [KEY 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🔒: Me (Contact#Contact#Self): Test – This is encrypted, signed, and has an Autocrypt Header without prefer-encrypt=mutual. √ +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_outgoing_mua_msg b/test-data/golden/test_outgoing_mua_msg index 549f84175..5d4a0f2ee 100644 --- a/test-data/golden/test_outgoing_mua_msg +++ b/test-data/golden/test_outgoing_mua_msg @@ -1,7 +1,4 @@ -Single#Chat#10: bob@example.net [bob@example.net] 🛡️ +Single#Chat#11: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- -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_outgoing_mua_msg_pgp b/test-data/golden/test_outgoing_mua_msg_pgp new file mode 100644 index 000000000..b30278135 --- /dev/null +++ b/test-data/golden/test_outgoing_mua_msg_pgp @@ -0,0 +1,6 @@ +Single#Chat#10: bob@example.net [KEY 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#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 index 4da4a67d0..df3a1f707 100644 --- a/test-data/golden/test_verified_oneonone_chat_enable_disable +++ b/test-data/golden/test_verified_oneonone_chat_enable_disable @@ -1,4 +1,4 @@ -Single#Chat#10: Bob [bob@example.net] 🛡️ +Single#Chat#10: Bob [PGP 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 🛡️❌] diff --git a/test-data/golden/verified_chats_message_from_old_dc_setup b/test-data/golden/verified_chats_message_from_old_dc_setup deleted file mode 100644 index 090fcae1d..000000000 --- a/test-data/golden/verified_chats_message_from_old_dc_setup +++ /dev/null @@ -1,8 +0,0 @@ -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): Now i have it! [FRESH] -Msg#12: info (Contact#Contact#Info): bob@example.net sent a message from another device. [NOTICED][INFO 🛡️❌] -Msg#13: (Contact#Contact#10): Soon i'll have a new device [FRESH] -Msg#14: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️] --------------------------------------------------------------------------------- diff --git a/test-data/message/mixed-up-long.eml b/test-data/message/mixed-up-long.eml index df51f1d96..dcadfeeb6 100644 --- a/test-data/message/mixed-up-long.eml +++ b/test-data/message/mixed-up-long.eml @@ -1,6 +1,3 @@ -From - Tue, 29 Aug 2023 20:24:31 GMT -X-Mozilla-Status: 0801 -X-Mozilla-Status2: 00000000 Message-ID: <05eae88e-35c9-5e5e-405f-11e8a3b44513@example.org> Date: Tue, 29 Aug 2023 17:24:31 -0300 MIME-Version: 1.0 @@ -9,10 +6,6 @@ User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Content-Language: en-US To: bob@example.net From: Alice -X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; - attachmentreminder=0; deliveryformat=0 -X-Identity-Key: id3 -Fcc: imap://alice%40example.org@in.example.org/Sent Subject: ... Content-Type: multipart/mixed; boundary="------------2IZJ0SaOTFMF25fU1nsH7bxg" diff --git a/test-data/message/rfc1847_encapsulation.eml b/test-data/message/rfc1847_encapsulation.eml index 25cb52d5d..5309d6394 100644 --- a/test-data/message/rfc1847_encapsulation.eml +++ b/test-data/message/rfc1847_encapsulation.eml @@ -1,60 +1,69 @@ -Message-ID: <4718cf7f-67f4-291b-ccb9-a167842729ed@example.org> -Date: Sun, 5 Dec 2021 00:00:00 +0000 -MIME-Version: 1.0 -User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 - Thunderbird/91.3.2 -Content-Language: en-US -To: Bob -From: Alice -Subject: ... -Content-Type: multipart/encrypted; - protocol="application/pgp-encrypted"; - boundary="------------68Kl9HSVGFVUMdZIowLUKskt" - -This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156) ---------------68Kl9HSVGFVUMdZIowLUKskt -Content-Type: application/pgp-encrypted -Content-Description: PGP/MIME version identification - -Version: 1 - ---------------68Kl9HSVGFVUMdZIowLUKskt -Content-Type: application/octet-stream; name="encrypted.asc" -Content-Description: OpenPGP encrypted message -Content-Disposition: inline; filename="encrypted.asc" - ------BEGIN PGP MESSAGE----- - -wV4D5tq63hTeebASAQdAt2c3rVUh+l0Ps7/Je83NaA7M6HsobtfMueqLUBaeancw0rRAo7PbLDLL -cVX3SiPw6qqZyD99JZEgxZJFWM2GVILGqdvJFl11OKqXUDbzRgq6wcBMA+PY3JvEjuMiAQf6An2O -xxjJsLgY3Ys6Ndqm8Tqp0XxK3gQuj5Vqpgd7Qv+57psL5jLHc46RxUR/txlY3Kay3yITG82iDvi4 -fbpkes7/t8eWOrtGdyPVokhfekuCLBoF24F4tEYBsumcurkNDqY1l+dxMzGB9goQWiVOUK3n+IV8 -fWPTazXTxO5o0VbCFU6RklpW07JEQUrmTzc+cwlIMhttU+h9rkfu8lm+9+KpI8GOHGV3RSCfZ1ns -PiZL2xgJsTXAb7dF4vaAWozS7BFfxGZ1DknrySGMUBV3nmDjy/na5YiOqe/PWaZE19LcYEUdR6K5 -AFyifXDAwi0EoMe9w+aFWqnvuOkPWnhTVNLEPAFlODnAMgqeFMfHCiIrRI/UcA/NUNuY/MCFUC17 -aAw4Gl4v/pGRnVU3H+4KhW7AqNuqXQC0SpqZDuLEfr5DqUtd7at9TJh+n3kACs7sMzj3pLmZwBcg -HddQoI35SuiLQwa79Ws/BwwSPKjRNYcKjwrjuG+k0gk+x5vd9PfUIX1ypatyJC5ZeIpFUiqPZYlg -RCzYaWkGvvSFKIOrEWHMcUaP1p51L3n4Bc8UjVcvoeXjD2w5/SzbQ9/gp8Pno+lk1F1StDOQcRGw -wzlKzw9KyznRCXtBtnGqgjr1gW2c1nt3BDBqq4KKTaf64eorkWOe29Qwk7jWkh+4HOe9uYd4raU3 -sLSY/LRSbYpJnNVsympMqPYopr7pO5W7sgqU1VFtfdCVZfzgvXi1USgnqQ++2BA253nrN203ZERL -sHwWPIjeo5kULPqV7tUfU0goc7uerEFeFjJOg+Z1ZNU9/fhfJYoJTbo+2Kd6v93PPPgGzxeAU+zL -in4yDAAJB9yJzkbVL83G7yfJ+3J5h+19aTc6XSlkXzNyLmQvTKFqDdq2SHooAlG7UJoE6vRK+mDz -vbND9KbAAtQ4aQp10OYNyb+ZSXiwsKrgxMP3FE3j6Ui7Q9Fp3GgJC5SR0gTcGwqRWODgQau8E26r -ukYKlB6XJ9tPAf2BwXeqwiQ3QU1704BzbO5G3tby9TpWqnAdtEfT2LdhllrwQmPWo+lNNWf1oLWu -ylhJ1yEWETzeClDFxeyAoehJLZImlISQQsEoEPxCqHZ60o9x6ANto6xv3CIbu0WziA2A6R7tweBi -mCAsyZdVCL2gg2nw+UWUyv6baTDpkxtKJOvYZeyzR0TH6KExRgeKjBrWPuHxJ7b+e70/DLvfNg+x -Q6pulf+LWDKgZ9bGCZWbutp2uFyvdW+RdJXXXmhSZ3nrhusw/PVdGeQz+3N6LK3yiVOcvLeyNqGW -/yYST6Rmqen0/JQPDDdKh4JjmLnJ/SmPTDOCD29uB03tCDDU2mzOUUncJWURE3jmJlKGGoOq4Ar9 -W03ud3E1ks/ZXk+aqz3jQ354cqSampZcxqX90esibuV/guUI3u0N3ah+FW1IfRhP2xJ36SIzc1lu -Bs/jehRDJ9/BSFH+lHRftcYoGjNNFzl7Hx4me8EDdfhzX0HXNUZhVYJlFktdr1cjhPNzlxlnCL8b -MgERav2VKFBvW0LR4Mm+trtbFU1ajybVihk7R56yJ/itnTHd3BxR7s8sRsG/6a8d2QiKjfNHBU05 -KEATHBFwTz3WWBbtBMN8fmIg8g2MrOfjcaHoTAgRJVr0rf+ww+KyaI8ZsraB+KTzXk+iVegNaUe/ -CiLI+Yl9ePNkFFbi4MyrY0ujXM6zRp7nbUlDewzGpI4LTyyAQ9IUqkCnAi0k7AkM1BIp8z1wxWlW -JRAnxGSzxgibYLZ9f/fd9vBAiYA1ZVsuZTN2iUtt2/VJr2K7zPHwgO4j2OLtR4DKazCd7IlrArRH -BfawosWYQ7cQJyo/+wxjXccvHVrZRn8vBvmFWdKz9mi1wC1HYyLeMJwYpaPsK79TRedA34pQSuAa -QkAO79MxOVnknYS8pEGxrwD9l9vxrlZEllnFtG+QJeXsZgMIjwCaByJs7I3skUAHcuimN1X8htU2 -ofVNpLp9SUsrtXbFp89Dxiuflj10VvcLGU2AjSsUtjEpPl0nobeJmA3RzFxJZ61RG+E= -=dcQr ------END PGP MESSAGE----- - ---------------68Kl9HSVGFVUMdZIowLUKskt-- +Message-ID: <4718cf7f-67f4-291b-ccb9-a167842729ed@example.org> +Date: Sun, 5 Dec 2021 00:00:00 +0000 +MIME-Version: 1.0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 + Thunderbird/91.3.2 +Content-Language: en-US +To: Bob +From: Alice +Subject: ... +Autocrypt: addr=alice@example.org; keydata= + xjMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5DN + GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz7CkAQTFggAOBYhBC5vossjtTLXKGNLWGSwj2Gp + 7ZRDBQJeWHXdAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEGSwj2Gp7ZRDE3oA/i4M + CyDMTsjWqDZoQwX/A/GoTO2/V0wKPhjJJy/8m2pMAPkBjOnGOtx2SZpQvJGTa9h804RY6iDr + RuI8A/8tEEXAA844BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp + 01JrRe6Xqy22HQMBCAfCeAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsM + AAoJEGSwj2Gp7ZRDLo8BAObE8GnsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIy + VfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp3jsMAg== +Content-Type: multipart/encrypted; + protocol="application/pgp-encrypted"; + boundary="------------68Kl9HSVGFVUMdZIowLUKskt" + +This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156) +--------------68Kl9HSVGFVUMdZIowLUKskt +Content-Type: application/pgp-encrypted +Content-Description: PGP/MIME version identification + +Version: 1 + +--------------68Kl9HSVGFVUMdZIowLUKskt +Content-Type: application/octet-stream; name="encrypted.asc" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wV4D5tq63hTeebASAQdAt2c3rVUh+l0Ps7/Je83NaA7M6HsobtfMueqLUBaeancw0rRAo7PbLDLL +cVX3SiPw6qqZyD99JZEgxZJFWM2GVILGqdvJFl11OKqXUDbzRgq6wcBMA+PY3JvEjuMiAQf6An2O +xxjJsLgY3Ys6Ndqm8Tqp0XxK3gQuj5Vqpgd7Qv+57psL5jLHc46RxUR/txlY3Kay3yITG82iDvi4 +fbpkes7/t8eWOrtGdyPVokhfekuCLBoF24F4tEYBsumcurkNDqY1l+dxMzGB9goQWiVOUK3n+IV8 +fWPTazXTxO5o0VbCFU6RklpW07JEQUrmTzc+cwlIMhttU+h9rkfu8lm+9+KpI8GOHGV3RSCfZ1ns +PiZL2xgJsTXAb7dF4vaAWozS7BFfxGZ1DknrySGMUBV3nmDjy/na5YiOqe/PWaZE19LcYEUdR6K5 +AFyifXDAwi0EoMe9w+aFWqnvuOkPWnhTVNLEPAFlODnAMgqeFMfHCiIrRI/UcA/NUNuY/MCFUC17 +aAw4Gl4v/pGRnVU3H+4KhW7AqNuqXQC0SpqZDuLEfr5DqUtd7at9TJh+n3kACs7sMzj3pLmZwBcg +HddQoI35SuiLQwa79Ws/BwwSPKjRNYcKjwrjuG+k0gk+x5vd9PfUIX1ypatyJC5ZeIpFUiqPZYlg +RCzYaWkGvvSFKIOrEWHMcUaP1p51L3n4Bc8UjVcvoeXjD2w5/SzbQ9/gp8Pno+lk1F1StDOQcRGw +wzlKzw9KyznRCXtBtnGqgjr1gW2c1nt3BDBqq4KKTaf64eorkWOe29Qwk7jWkh+4HOe9uYd4raU3 +sLSY/LRSbYpJnNVsympMqPYopr7pO5W7sgqU1VFtfdCVZfzgvXi1USgnqQ++2BA253nrN203ZERL +sHwWPIjeo5kULPqV7tUfU0goc7uerEFeFjJOg+Z1ZNU9/fhfJYoJTbo+2Kd6v93PPPgGzxeAU+zL +in4yDAAJB9yJzkbVL83G7yfJ+3J5h+19aTc6XSlkXzNyLmQvTKFqDdq2SHooAlG7UJoE6vRK+mDz +vbND9KbAAtQ4aQp10OYNyb+ZSXiwsKrgxMP3FE3j6Ui7Q9Fp3GgJC5SR0gTcGwqRWODgQau8E26r +ukYKlB6XJ9tPAf2BwXeqwiQ3QU1704BzbO5G3tby9TpWqnAdtEfT2LdhllrwQmPWo+lNNWf1oLWu +ylhJ1yEWETzeClDFxeyAoehJLZImlISQQsEoEPxCqHZ60o9x6ANto6xv3CIbu0WziA2A6R7tweBi +mCAsyZdVCL2gg2nw+UWUyv6baTDpkxtKJOvYZeyzR0TH6KExRgeKjBrWPuHxJ7b+e70/DLvfNg+x +Q6pulf+LWDKgZ9bGCZWbutp2uFyvdW+RdJXXXmhSZ3nrhusw/PVdGeQz+3N6LK3yiVOcvLeyNqGW +/yYST6Rmqen0/JQPDDdKh4JjmLnJ/SmPTDOCD29uB03tCDDU2mzOUUncJWURE3jmJlKGGoOq4Ar9 +W03ud3E1ks/ZXk+aqz3jQ354cqSampZcxqX90esibuV/guUI3u0N3ah+FW1IfRhP2xJ36SIzc1lu +Bs/jehRDJ9/BSFH+lHRftcYoGjNNFzl7Hx4me8EDdfhzX0HXNUZhVYJlFktdr1cjhPNzlxlnCL8b +MgERav2VKFBvW0LR4Mm+trtbFU1ajybVihk7R56yJ/itnTHd3BxR7s8sRsG/6a8d2QiKjfNHBU05 +KEATHBFwTz3WWBbtBMN8fmIg8g2MrOfjcaHoTAgRJVr0rf+ww+KyaI8ZsraB+KTzXk+iVegNaUe/ +CiLI+Yl9ePNkFFbi4MyrY0ujXM6zRp7nbUlDewzGpI4LTyyAQ9IUqkCnAi0k7AkM1BIp8z1wxWlW +JRAnxGSzxgibYLZ9f/fd9vBAiYA1ZVsuZTN2iUtt2/VJr2K7zPHwgO4j2OLtR4DKazCd7IlrArRH +BfawosWYQ7cQJyo/+wxjXccvHVrZRn8vBvmFWdKz9mi1wC1HYyLeMJwYpaPsK79TRedA34pQSuAa +QkAO79MxOVnknYS8pEGxrwD9l9vxrlZEllnFtG+QJeXsZgMIjwCaByJs7I3skUAHcuimN1X8htU2 +ofVNpLp9SUsrtXbFp89Dxiuflj10VvcLGU2AjSsUtjEpPl0nobeJmA3RzFxJZ61RG+E= +=dcQr +-----END PGP MESSAGE----- + +--------------68Kl9HSVGFVUMdZIowLUKskt-- diff --git a/test-data/message/thunderbird_encrypted_signed_with_pubkey.eml b/test-data/message/thunderbird_encrypted_signed_with_pubkey.eml index 8eeb6d892..6acba5422 100644 --- a/test-data/message/thunderbird_encrypted_signed_with_pubkey.eml +++ b/test-data/message/thunderbird_encrypted_signed_with_pubkey.eml @@ -1,6 +1,3 @@ -From - Thu, 02 Nov 2023 05:20:27 GMT -X-Mozilla-Status: 0801 -X-Mozilla-Status2: 00000000 Message-ID: <956fad6d-206e-67af-2443-3ea5819418ff@example.org> Date: Thu, 2 Nov 2023 02:20:27 -0300 MIME-Version: 1.0 @@ -43,10 +40,6 @@ Autocrypt: addr=alice@example.org; keydata= MGDpk/1NVuMnIHJESRg/SSFV6sElgq38k9wAT2oUqLcYvYI07nHmnuciaGygkCcGt+l2PvAa j4mkQQvMU0cNRDBybk5aKi820oGIJjT7e+5RnD2mYZQdOAbQhDVCHvrfS1I60bsHT1MHqyAa /qMLjKwBpKEd/w== -X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; - attachmentreminder=0; deliveryformat=0 -X-Identity-Key: id3 -Fcc: imap://alice%40example.org@in.example.org/Sent Subject: ... Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; diff --git a/test-data/message/thunderbird_encrypted_unsigned_with_unencrypted_subject.eml b/test-data/message/thunderbird_encrypted_unsigned_with_unencrypted_subject.eml index 2375e7ade..c43658e28 100644 --- a/test-data/message/thunderbird_encrypted_unsigned_with_unencrypted_subject.eml +++ b/test-data/message/thunderbird_encrypted_unsigned_with_unencrypted_subject.eml @@ -1,6 +1,3 @@ -From - Sun, 19 Nov 2023 01:08:24 GMT -X-Mozilla-Status: 0800 -X-Mozilla-Status2: 00000000 Message-ID: <38a2a29b-8261-403b-abb5-56b0a87d2ff4@example.org> Date: Sat, 18 Nov 2023 22:08:23 -0300 MIME-Version: 1.0 @@ -42,10 +39,6 @@ Autocrypt: addr=alice@example.org; keydata= MGDpk/1NVuMnIHJESRg/SSFV6sElgq38k9wAT2oUqLcYvYI07nHmnuciaGygkCcGt+l2PvAa j4mkQQvMU0cNRDBybk5aKi820oGIJjT7e+5RnD2mYZQdOAbQhDVCHvrfS1I60bsHT1MHqyAa /qMLjKwBpKEd/w== -X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; - attachmentreminder=0; deliveryformat=0 -X-Identity-Key: id3 -Fcc: imap://alice%40example.org@in.example.org/Sent Subject: Hello! Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; diff --git a/test-data/message/thunderbird_signed_unencrypted.eml b/test-data/message/thunderbird_signed_unencrypted.eml index ec9646050..f0338abb9 100644 --- a/test-data/message/thunderbird_signed_unencrypted.eml +++ b/test-data/message/thunderbird_signed_unencrypted.eml @@ -1,6 +1,3 @@ -From - Thu, 15 Dec 2022 14:45:17 GMT -X-Mozilla-Status: 0801 -X-Mozilla-Status2: 00000000 Message-ID: Date: Thu, 15 Dec 2022 11:45:16 -0300 MIME-Version: 1.0 @@ -10,10 +7,6 @@ Content-Language: en-US To: bob@example.net From: Alice Subject: test message 15:53 -X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; - attachmentreminder=0; deliveryformat=0 -X-Identity-Key: id3 -Fcc: imap://alice%40example.org@in.example.org/Sent Content-Type: multipart/signed; micalg=pgp-sha256; protocol="application/pgp-signature"; boundary="------------iX39J1p7DOgblwacjo0e7jX7" diff --git a/test-data/message/thunderbird_with_autocrypt_unencrypted.eml b/test-data/message/thunderbird_with_autocrypt_unencrypted.eml index 201044751..a3c3a5389 100644 --- a/test-data/message/thunderbird_with_autocrypt_unencrypted.eml +++ b/test-data/message/thunderbird_with_autocrypt_unencrypted.eml @@ -1,6 +1,3 @@ -From - Wed, 14 Dec 2022 18:53:03 GMT -X-Mozilla-Status: 0801 -X-Mozilla-Status2: 00000000 Message-ID: <87d75c7e-0f52-1335-e437-af605c09f954@example.org> Date: Wed, 14 Dec 2022 15:53:03 -0300 MIME-Version: 1.0 @@ -44,10 +41,6 @@ Autocrypt: addr=alice@example.org; keydata= MGDpk/1NVuMnIHJESRg/SSFV6sElgq38k9wAT2oUqLcYvYI07nHmnuciaGygkCcGt+l2PvAa j4mkQQvMU0cNRDBybk5aKi820oGIJjT7e+5RnD2mYZQdOAbQhDVCHvrfS1I60bsHT1MHqyAa /qMLjKwBpKEd/w== -X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; - attachmentreminder=0; deliveryformat=0 -X-Identity-Key: id3 -Fcc: imap://alice%40example.org@in.example.org/Sent Content-Type: multipart/signed; micalg=pgp-sha256; protocol="application/pgp-signature"; boundary="------------x6XEHrf0vHmVgEo6f9bMGGUy"