From 67224ed4e04e127e859c01deed325f9e4afc654b Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 18 Jun 2026 01:04:01 +0200 Subject: [PATCH 1/2] refactor(mimefactory): separate rendering of message payload and sendable message This change separates rendering into two separate steps: 1. Rendering of the message payload without the From, Date and Autocrypt headers. 2. Adding the From, Date and Autocrypt headers and possibly encrypting the message. The goal is to have serializable result of the first step that can be persisted in the database and sent later with any email address. This way it will be possible to send queued messages over any relay. This will make it possible not to remove all messages from the queue when the sending relay is changed. Currently changing `configured_addr` deletes everything from `smtp` table. This change is however only a refactoring and does not implement any features. --- src/chat.rs | 60 ++- src/download.rs | 6 +- src/e2ee.rs | 100 +---- src/mimefactory.rs | 977 +++++++++++++++++++++++++++------------------ src/pgp.rs | 211 +++++----- src/reaction.rs | 3 +- src/test_utils.rs | 32 +- 7 files changed, 768 insertions(+), 621 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 0fd5ffca4f..e93f9832c9 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -35,11 +35,13 @@ use crate::download::{ use crate::ensure_and_debug_assert_eq; use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers}; use crate::events::EventType; +use crate::key; use crate::key::{Fingerprint, self_fingerprint}; use crate::location; use crate::log::{LogExt, warn}; use crate::logged_debug_assert; use crate::message::{self, Message, MessageState, MsgId, Viewtype}; +use crate::mimefactory; use crate::mimefactory::{MimeFactory, RenderedEmail}; use crate::mimeparser::SystemMessage; use crate::param::{Param, Params}; @@ -2766,6 +2768,10 @@ async fn render_mime_message_and_pre_message( msg: &mut Message, mimefactory: MimeFactory, ) -> Result<(Option, RenderedEmail)> { + let from_addr = context.get_primary_self_addr().await?; + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; + let needs_pre_message = msg.viewtype.has_file() && mimefactory.will_be_encrypted() // unencrypted is likely email, we don't want to spam by sending multiple messages && msg @@ -2782,15 +2788,31 @@ async fn render_mime_message_and_pre_message( let mut mimefactory_post_msg = mimefactory.clone(); mimefactory_post_msg.set_as_post_message(); - let rendered_msg = Box::pin(mimefactory_post_msg.render(context)) + let (queued_msg, side_effects) = Box::pin(mimefactory_post_msg.into_queued_mail(context)) .await .context("Failed to render post-message")?; + let rendered_msg = mimefactory::render_queued_mail( + queued_msg, + &public_key, + &secret_key, + from_addr.clone(), + side_effects, + )?; + let mut mimefactory_pre_msg = mimefactory; mimefactory_pre_msg.set_as_pre_message_for(&rendered_msg); - let rendered_pre_msg = Box::pin(mimefactory_pre_msg.render(context)) - .await - .context("pre-message failed to render")?; + let (queued_pre_msg, pre_side_effects) = + Box::pin(mimefactory_pre_msg.into_queued_mail(context)) + .await + .context("pre-message failed to render")?; + let rendered_pre_msg = mimefactory::render_queued_mail( + queued_pre_msg, + &public_key, + &secret_key, + from_addr, + pre_side_effects, + )?; if rendered_pre_msg.message.len() > PRE_MSG_SIZE_WARNING_THRESHOLD { warn!( @@ -2803,7 +2825,16 @@ async fn render_mime_message_and_pre_message( Ok((Some(rendered_pre_msg), rendered_msg)) } else { - Ok((None, Box::pin(mimefactory.render(context)).await?)) + let (queued_msg, side_effects) = Box::pin(mimefactory.into_queued_mail(context)).await?; + let rendered_msg = mimefactory::render_queued_mail( + queued_msg, + &public_key, + &secret_key, + from_addr, + side_effects, + )?; + + Ok((None, rendered_msg)) } } @@ -2848,7 +2879,6 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - return Err(err); } }; - let attach_selfavatar = mimefactory.attach_selfavatar; let mut recipients = mimefactory.recipients(); // Default Webxdc integrations are hidden messages and must not be sent out: @@ -2930,14 +2960,22 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - let now = time(); - if let Some(last_added_location_timestamp) = rendered_msg.last_added_location_timestamp { + if let Some(last_added_location_timestamp) = + rendered_msg.side_effects.last_added_location_timestamp + { location::set_kml_sent_timestamp(context, msg.chat_id, last_added_location_timestamp) .await?; } - if attach_selfavatar && let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await + if rendered_msg.side_effects.avatar_is_attached + || rendered_pre_msg + .as_ref() + .is_some_and(|msg| msg.side_effects.avatar_is_attached) { - error!(context, "Failed to set selfavatar timestamp: {err:#}."); + msg.chat_id + .set_selfavatar_timestamp(context, now) + .await + .context("Failed to set selfavatar timestamp")?; } if rendered_msg.is_encrypted { @@ -2945,7 +2983,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - } else { msg.param.remove(Param::GuaranteeE2ee); } - msg.subject.clone_from(&rendered_msg.subject); + msg.subject.clone_from(&rendered_msg.side_effects.subject); // Sort the message to the bottom. Employ `msgs_index7` to compute `timestamp`. context .sql @@ -2977,7 +3015,7 @@ WHERE id=? let trans_fn = |t: &mut rusqlite::Transaction| { let mut row_ids = Vec::::new(); - if let Some(sync_ids) = rendered_msg.sync_ids_to_delete { + if let Some(sync_ids) = rendered_msg.side_effects.sync_ids_to_delete { t.execute( &format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"), (), diff --git a/src/download.rs b/src/download.rs index c7f5f09d1e..e1bfd86c9f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -163,7 +163,9 @@ pub(crate) async fn download_msg( delete_from_available_post_msgs(context, &rfc724_mid).await?; return Ok(None); }; - Box::pin(session.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)).await?; + session + .fetch_single_msg(context, &server_folder, server_uid, rfc724_mid) + .await?; let bcc_self = context.get_config_bool(Config::BccSelf).await?; if ephemeral::should_delete_all_downloaded_messages(bcc_self, session.is_chatmail()) { @@ -204,7 +206,7 @@ impl Session { let (sender, receiver) = async_channel::unbounded(); { let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await; - self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender) + Box::pin(self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)) .await?; } if receiver.recv().await.is_err() { diff --git a/src/e2ee.rs b/src/e2ee.rs index 77606419c2..8e25272ff6 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -1,105 +1,9 @@ //! End-to-end encryption support. -use std::io::Cursor; - -use anyhow::Result; -use mail_builder::mime::MimePart; - -use crate::aheader::{Aheader, EncryptPreference}; -use crate::context::Context; -use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key}; -use crate::pgp::{self, SeipdVersion}; - -#[derive(Debug)] -pub struct EncryptHelper { - pub addr: String, - pub public_key: SignedPublicKey, -} - -impl EncryptHelper { - pub async fn new(context: &Context) -> Result { - let addr = context.get_primary_self_addr().await?; - let public_key = load_self_public_key(context).await?; - - Ok(EncryptHelper { addr, public_key }) - } - - pub fn get_aheader(&self) -> Aheader { - Aheader { - addr: self.addr.clone(), - public_key: self.public_key.clone(), - prefer_encrypt: EncryptPreference::Mutual, - verified: false, - } - } - - /// Tries to encrypt the passed in `mail`. - pub async fn encrypt( - self, - context: &Context, - keyring: Vec, - mail_to_encrypt: MimePart<'static>, - compress: bool, - seipd_version: SeipdVersion, - ) -> Result { - let mut raw_message = Vec::new(); - let cursor = Cursor::new(&mut raw_message); - mail_to_encrypt.clone().write_part(cursor).ok(); - - let ctext = self - .encrypt_raw(context, keyring, raw_message, compress, seipd_version) - .await?; - Ok(ctext) - } - - pub async fn encrypt_raw( - self, - context: &Context, - keyring: Vec, - raw_message: Vec, - compress: bool, - seipd_version: SeipdVersion, - ) -> Result { - let sign_key = load_self_secret_key(context).await?; - let ctext = - pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?; - - Ok(ctext) - } - - /// Symmetrically encrypt the message. This is used for broadcast channels. - /// `shared secret` is the secret that will be used for symmetric encryption. - pub async fn encrypt_symmetrically( - self, - context: &Context, - shared_secret: &str, - mail_to_encrypt: MimePart<'static>, - compress: bool, - sign: bool, - ) -> Result { - let sign_key = if sign { - Some(load_self_secret_key(context).await?) - } else { - None - }; - - let shared_secret = shared_secret.to_string(); - let mut raw_message = Vec::new(); - let cursor = Cursor::new(&mut raw_message); - mail_to_encrypt.clone().write_part(cursor).ok(); - - let ctext = tokio::task::spawn_blocking(move || { - pgp::symm_encrypt_message(raw_message, sign_key, shared_secret, compress) - }) - .await??; - - Ok(ctext) - } -} - #[cfg(test)] mod tests { - use super::*; + use anyhow::Result; + use crate::chat; use crate::chat::send_text_msg; use crate::config::Config; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 169a474424..e5d586260c 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -21,11 +21,11 @@ use crate::constants::{BROADCAST_INCOMPATIBILITY_MSG, Chattype, DC_FROM_HANDSHAK use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::download::PostMsgMetadata; -use crate::e2ee::EncryptHelper; use crate::ensure_and_debug_assert; use crate::ephemeral::Timer as EphemeralTimer; use crate::headerdef::HeaderDef; -use crate::key::{DcKey, SignedPublicKey, load_self_public_key, self_fingerprint}; +use crate::key; +use crate::key::{DcKey, SignedPublicKey, SignedSecretKey, load_self_public_key, self_fingerprint}; use crate::location; use crate::log::warn; use crate::message::{Message, MsgId, Viewtype}; @@ -142,43 +142,333 @@ pub struct MimeFactory { /// using `Chat-Disposition-Notification-To` header. req_mdn: bool, + /// True if the avatar should be attached. + attach_selfavatar: bool, + + /// This field is used to sustain the topic id of webxdcs needed for peer channels. + webxdc_topic: Option, + + /// Pre-message / post-message / atomic message. + pre_message_mode: PreMessageMode, +} + +/// Result of rendering non-MDN message. +pub struct RenderedMessage { + main_part: MimePart<'static>, + + parts: Vec>, + /// Largest timestamp of the location sent in `location.kml` in this message. last_added_location_timestamp: Option, + /// True if the avatar is attached to the message. + avatar_is_attached: bool, + /// If the created mime-structure contains sync-items, /// the IDs of these items are listed here. /// The IDs are returned via `RenderedEmail` /// and must be deleted if the message is actually queued for sending. sync_ids_to_delete: Option, +} - /// True if the avatar should be attached. - pub attach_selfavatar: bool, +/// Email message queued, but not sent yet. +/// +/// It is stored unencrypted to +/// make it possible to change protected headers +/// like the From address and Autocrypt header later. +#[derive(Debug, Clone)] +pub(crate) struct QueuedMail { + /// Unencrypted queued message. + /// + /// This message has both the headers and the body, + /// but without the From, Autocrypt and Message-ID headers. + /// + /// For encrypted messages this is the OpenPGP payload. + raw_message: Vec, - /// This field is used to sustain the topic id of webxdcs needed for peer channels. - webxdc_topic: Option, + /// Display name to put in the `From:` field. + /// + /// Email address is not determined yet here. + display_name: String, - /// Pre-message / post-message / atomic message. - pre_message_mode: PreMessageMode, -} + /// Message-ID. + rfc724_mid: String, -/// Result of rendering a message, ready to be submitted to a send job. -#[derive(Debug, Clone)] -pub struct RenderedEmail { - pub message: String, - pub is_encrypted: bool, + /// Public keys to which the message should be encrypted. + encryption_pubkeys: Option>, + + /// Shared secret if the message should be encrypted symmetrically. + shared_secret: Option, + + /// If true, Autocrypt header should be added before sending. + should_attach_pubkey: bool, + + /// If true, OpenPGP compression may be used. + should_compress: bool, + /// If true, encrypted message should be signed as well. + should_sign: bool, +} + +/// Side effects that should be applied at the same time +/// as the message is persisted in the queue. +#[derive(Debug, Clone, Default)] +pub struct RenderSideEffects { /// Largest timestamp of the location sent in `location.kml` in this message. pub last_added_location_timestamp: Option, + /// True if the message has the avatar attached. + /// + /// Timestamp of the last time avatar was gossiped should be updated. + pub avatar_is_attached: bool, + /// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted /// from `multi_device_sync` once the message is actually queued for sending. pub sync_ids_to_delete: Option, + /// Subject that was rendered into the message. + /// + /// Used to update the subject on the sent message object. + pub subject: String, +} + +/// Renders [`QueuedMail`]. +/// +/// Adds headers: +/// - `From` +/// - `Autocrypt` +/// - `Message-ID` +/// +/// Encrypts and signs the message if necessary. +pub(crate) fn render_queued_mail( + queued_mail: QueuedMail, + public_key: &SignedPublicKey, + secret_key: &SignedSecretKey, + from_addr: String, + side_effects: RenderSideEffects, +) -> Result { + let QueuedMail { + rfc724_mid, + display_name, + raw_message, + encryption_pubkeys, + shared_secret, + should_attach_pubkey, + should_compress, + should_sign, + } = queued_mail; + + let mut inner_headers: Vec = Vec::new(); + let mut outer_headers: Vec = Vec::new(); + + let is_encrypted = encryption_pubkeys.is_some(); + + fn add_header( + name: &[u8], + value: &impl mail_builder::headers::Header, + headers: &mut Vec, + ) -> Result<()> { + headers.extend(name); + value.write_header(headers, name.len())?; + Ok(()) + } + + let from_header = new_address_with_name(&display_name, from_addr.clone()); + add_header(b"From: ", &from_header, &mut inner_headers)?; + + if is_encrypted { + let unencrypted_from = Address::new_address(None::<&'static str>, from_addr.clone()); + add_header(b"From: ", &unencrypted_from, &mut outer_headers)?; + add_header(b"HP-Outer: From: ", &unencrypted_from, &mut inner_headers)?; + } else { + add_header(b"From: ", &from_header, &mut outer_headers)?; + } + + if is_encrypted { + // Randomized date goes to unprotected header. + // + // We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000" + // or omit the header because GMX then fails with + // + // host mx00.emig.gmx.net[212.227.15.9] said: + // 554-Transaction failed + // 554-Reject due to policy restrictions. + // 554 For explanation visit https://postmaster.gmx.net/en/case?... + // (in reply to end of DATA command) + // + // and the explanation page says + // "The time information deviates too much from the actual time". + // + // We also limit the range to 6 days (518400 seconds) + // because with a larger range we got + // error "500 Date header far in the past/future" + // which apparently originates from Symantec Messaging Gateway + // and means the message has a Date that is more + // than 7 days in the past: + // + let timestamp = time(); + let timestamp_offset = rand::random_range(0..518400); + let protected_timestamp = timestamp.saturating_sub(timestamp_offset); + let unprotected_date = + chrono::DateTime::::from_timestamp(protected_timestamp, 0) + .unwrap() + .to_rfc2822(); + outer_headers.extend(b"Date: "); + outer_headers.extend(unprotected_date.as_bytes()); + outer_headers.extend(b"\r\n"); + + inner_headers.extend(b"HP-Outer: Date: "); + inner_headers.extend(unprotected_date.as_bytes()); + inner_headers.extend(b"\r\n"); + } + + inner_headers.extend(b"Message-ID: <"); + inner_headers.extend(rfc724_mid.as_bytes()); + inner_headers.extend(b">\r\n"); + outer_headers.extend(b"Message-ID: <"); + outer_headers.extend(rfc724_mid.as_bytes()); + outer_headers.extend(b">\r\n"); + if is_encrypted { + inner_headers.extend(b"HP-Outer: Message-ID: <"); + inner_headers.extend(rfc724_mid.as_bytes()); + inner_headers.extend(b">\r\n"); + } + + // MIME header . + outer_headers.extend(b"MIME-Version: 1.0\r\n"); + + if should_attach_pubkey { + let aheader = Aheader { + addr: from_addr, + public_key: public_key.clone(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false, + }; + let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string()); + add_header( + b"Autocrypt: ", + &autocrypt_header, + if is_encrypted { + &mut inner_headers + } else { + &mut outer_headers + }, + )?; + } + + if is_encrypted { + // Copy not protected headers to outer headers. + let (parsed_headers, _index) = mailparse::parse_headers(&raw_message)?; + for parsed_header in parsed_headers { + let original_header_name = parsed_header.get_key(); + let header_name = original_header_name.to_lowercase(); + + if header_name == "mime-version" + || header_name == "content-type" + || header_name == "content-transfer-encoding" + || header_name == "content-disposition" + { + // Structural headers shouldn't be added as "HP-Outer". They are defined in + // . + continue; + } + let header_value = + if header_name == "chat-version" || header_name == "chat-is-post-message" { + parsed_header.get_value_raw() + } else if header_name == "subject" { + &b"[...]"[..] + } else if header_name == "to" { + &b"\"hidden-recipients\": ;"[..] + } else { + continue; + }; + + outer_headers.extend(original_header_name.as_bytes()); + outer_headers.extend(b": "); + outer_headers.extend(header_value); + outer_headers.extend(b"\r\n"); + + inner_headers.extend(b"HP-Outer: "); + inner_headers.extend(original_header_name.as_bytes()); + inner_headers.extend(b": "); + inner_headers.extend(header_value); + inner_headers.extend(b"\r\n"); + } + } + + let message = if let Some(encryption_pubkeys) = encryption_pubkeys { + let mut full_raw_message = inner_headers.clone(); + full_raw_message.extend(raw_message); + + let encrypted = if let Some(shared_secret) = shared_secret { + let sign_key = if should_sign { + Some(secret_key.clone()) + } else { + None + }; + + crate::pgp::symm_encrypt_message( + full_raw_message, + sign_key, + shared_secret, + should_compress, + )? + } else { + // Asymmetric encryption + + // Use SEIPDv2 if all recipients support it. + let seipd_version = if encryption_pubkeys + .iter() + .all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey)) + { + SeipdVersion::V2 + } else { + SeipdVersion::V1 + }; + + // Encrypt to self unconditionally, + // even for a single-device setup, + // to not reveal if we have a multi-device setup to contacts. + let mut encryption_keyring = vec![public_key.clone()]; + encryption_keyring.extend(encryption_pubkeys.iter().map(|(_addr, key)| (*key).clone())); + + crate::pgp::pk_encrypt( + full_raw_message, + encryption_keyring, + secret_key.clone(), + should_compress, + seipd_version, + )? + }; + + let message = wrap_encrypted_part(encrypted); + + part_to_bytes(message) + } else { + raw_message + }; + + let mut full_message = outer_headers; + full_message.extend(message); + Ok(RenderedEmail { + message: String::from_utf8_lossy(&full_message).to_string(), + is_encrypted, + rfc724_mid, + side_effects, + }) +} + +/// Result of rendering a message, ready to be submitted to a send job. +#[derive(Debug, Clone)] +pub struct RenderedEmail { + pub message: String, + + pub is_encrypted: bool, + /// Message ID (Message in the sense of Email) pub rfc724_mid: String, - /// Message subject. - pub subject: String, + pub side_effects: RenderSideEffects, } fn new_address_with_name(name: &str, address: String) -> Address<'static> { @@ -558,8 +848,6 @@ impl MimeFactory { in_reply_to, references, req_mdn, - last_added_location_timestamp: None, - sync_ids_to_delete: None, attach_selfavatar, webxdc_topic, pre_message_mode: PreMessageMode::None, @@ -611,8 +899,6 @@ impl MimeFactory { in_reply_to: String::default(), references: Vec::new(), req_mdn: false, - last_added_location_timestamp: None, - sync_ids_to_delete: None, attach_selfavatar: false, webxdc_topic: None, pre_message_mode: PreMessageMode::None, @@ -796,53 +1082,45 @@ impl MimeFactory { self.recipients.clone() } - /// Consumes a `MimeFactory` and renders it into a message which is then stored in - /// `smtp`-table to be used by the SMTP loop - #[expect(clippy::arithmetic_side_effects)] - pub async fn render(mut self, context: &Context) -> Result { - let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); - - let from = new_address_with_name(&self.from_displayname, self.from_addr.clone()); - - let mut to: Vec> = Vec::new(); - for (name, addr) in &self.to { - to.push(Address::new_address( - if name.is_empty() { - None - } else { - Some(name.to_string()) - }, - addr.clone(), - )); - } - - let mut past_members: Vec> = Vec::new(); // Contents of `Chat-Group-Past-Members` header. - for (name, addr) in &self.past_members { - past_members.push(Address::new_address( - if name.is_empty() { - None - } else { - Some(name.to_string()) - }, - addr.clone(), - )); - } - + async fn render_headers( + &mut self, + context: &Context, + subject_str: &str, + ) -> Result)>> { ensure_and_debug_assert!( self.member_timestamps.is_empty() - || to.len() + past_members.len() == self.member_timestamps.len(), - "to.len() ({}) + past_members.len() ({}) != self.member_timestamps.len() ({})", - to.len(), - past_members.len(), + || self.to.len().checked_add(self.past_members.len()) + == Some(self.member_timestamps.len()), + "self.to.len() ({}) + self.past_members.len() ({}) != self.member_timestamps.len() ({})", + self.to.len(), + self.past_members.len(), self.member_timestamps.len(), ); - if to.is_empty() { - to.push(hidden_recipients()); - } - // Start with Internet Message Format headers in the order of the standard example - // . - headers.push(("From", from.into())); + let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); + + let to: Vec> = if self.to.is_empty() { + vec![hidden_recipients()] + } else { + self.to + .iter() + .map(|(name, addr)| { + Address::new_address( + if name.is_empty() { + None + } else { + Some(name.to_string()) + }, + addr.clone(), + ) + }) + .collect() + }; + + let date = chrono::DateTime::::from_timestamp(self.timestamp, 0) + .unwrap() + .to_rfc2822(); + headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into())); if let Some(sender_displayname) = &self.sender_displayname { let sender = new_address_with_name(sender_displayname, self.from_addr.clone()); @@ -852,10 +1130,25 @@ impl MimeFactory { "To", mail_builder::headers::address::Address::new_list(to.clone()).into(), )); - if !past_members.is_empty() { + + if !self.past_members.is_empty() { + let past_members: Vec> = self + .past_members + .iter() + .map(|(name, addr)| { + Address::new_address( + if name.is_empty() { + None + } else { + Some(name.to_string()) + }, + addr.clone(), + ) + }) + .collect(); headers.push(( "Chat-Group-Past-Members", - mail_builder::headers::address::Address::new_list(past_members.clone()).into(), + mail_builder::headers::address::Address::new_list(past_members).into(), )); } @@ -891,35 +1184,11 @@ impl MimeFactory { } } - let subject_str = self.subject_str(context).await?; headers.push(( "Subject", mail_builder::headers::text::Text::new(subject_str.to_string()).into(), )); - let date = chrono::DateTime::::from_timestamp(self.timestamp, 0) - .unwrap() - .to_rfc2822(); - headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into())); - - let rfc724_mid = match &self.loaded { - Loaded::Message { msg, .. } => match &self.pre_message_mode { - PreMessageMode::Pre { .. } => { - if msg.pre_rfc724_mid.is_empty() { - create_outgoing_rfc724_mid() - } else { - msg.pre_rfc724_mid.clone() - } - } - _ => msg.rfc724_mid.clone(), - }, - Loaded::Mdn { .. } => create_outgoing_rfc724_mid(), - }; - headers.push(( - "Message-ID", - mail_builder::headers::message_id::MessageId::new(rfc724_mid.clone()).into(), - )); - // Reply headers as in . if !self.in_reply_to.is_empty() { headers.push(( @@ -987,7 +1256,6 @@ impl MimeFactory { } } - // Non-standard headers. headers.push(( "Chat-Version", mail_builder::headers::raw::Raw::new("1.0").into(), @@ -1003,21 +1271,6 @@ impl MimeFactory { )); } - let grpimage = self.grpimage(); - let should_attach_pubkey = match &self.loaded { - Loaded::Message { .. } => true, - Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?, - }; - let encrypt_helper = EncryptHelper::new(context).await?; - - if should_attach_pubkey { - let aheader = encrypt_helper.get_aheader().to_string(); - headers.push(( - "Autocrypt", - mail_builder::headers::raw::Raw::new(aheader).into(), - )); - } - if self.pre_message_mode == PreMessageMode::Post { headers.push(( "Chat-Is-Post-Message", @@ -1034,8 +1287,6 @@ impl MimeFactory { )); } - let is_encrypted = self.will_be_encrypted(); - // Add ephemeral timer for non-MDN messages. // For MDNs it does not matter because they are not visible // and ignored by the receiver. @@ -1049,18 +1300,75 @@ impl MimeFactory { } } - let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { - msg.param.get_cmd() == SystemMessage::SecurejoinMessage - } else { - false + Ok(headers) + } + + /// Helper function render the messages that are not queued. + /// + /// Used for MDNs because they are fully rendered and sent in one go, + /// rather than first creating a [`QueuedMail`] and sending it later. + pub async fn render(self, context: &Context) -> Result { + let from_addr = context.get_primary_self_addr().await?; + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; + let (queued_mail, side_effects) = Box::pin(self.into_queued_mail(context)).await?; + let rendered_mail = render_queued_mail( + queued_mail, + &public_key, + &secret_key, + from_addr, + side_effects, + )?; + Ok(rendered_mail) + } + + /// Consumes a `MimeFactory` and renders it into a message which is then stored in + /// `smtp`-table to be used by the SMTP loop + #[expect(clippy::arithmetic_side_effects)] + pub(crate) async fn into_queued_mail( + mut self, + context: &Context, + ) -> Result<(QueuedMail, RenderSideEffects)> { + let rfc724_mid = match &self.loaded { + Loaded::Message { msg, .. } => match &self.pre_message_mode { + PreMessageMode::Pre { .. } => { + if msg.pre_rfc724_mid.is_empty() { + create_outgoing_rfc724_mid() + } else { + msg.pre_rfc724_mid.clone() + } + } + _ => msg.rfc724_mid.clone(), + }, + Loaded::Mdn { .. } => create_outgoing_rfc724_mid(), }; + let subject_str = self.subject_str(context).await?; + let mut headers = self.render_headers(context, &subject_str).await?; + + let grpimage = self.grpimage(); + + let is_encrypted = self.will_be_encrypted(); + + let last_added_location_timestamp; + let avatar_is_attached; + let sync_ids_to_delete; + let message: MimePart<'static> = match &self.loaded { Loaded::Message { msg, .. } => { let msg = msg.clone(); - let (main_part, mut parts) = self + let RenderedMessage { + main_part, + mut parts, + last_added_location_timestamp: tmp_last_added_location_timestamp, + avatar_is_attached: tmp_avatar_is_attached, + sync_ids_to_delete: tmp_sync_ids_to_delete, + } = self .render_message(context, &mut headers, &grpimage, is_encrypted) .await?; + last_added_location_timestamp = tmp_last_added_location_timestamp; + avatar_is_attached = tmp_avatar_is_attached; + sync_ids_to_delete = tmp_sync_ids_to_delete; if parts.is_empty() { // Single part, render as regular message. main_part @@ -1077,29 +1385,54 @@ impl MimeFactory { } } } - Loaded::Mdn { .. } => self.render_mdn()?, + Loaded::Mdn { .. } => { + last_added_location_timestamp = None; + avatar_is_attached = false; + sync_ids_to_delete = None; + self.render_mdn()? + } }; - let HeadersByConfidentiality { - mut unprotected_headers, - hidden_headers, - protected_headers, - } = group_headers_by_confidentiality( - headers, - &self.from_addr, - self.timestamp, - is_encrypted, - is_securejoin_message, - ); + let should_attach_pubkey = match &self.loaded { + Loaded::Message { .. } => true, + Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?, + }; + let is_post_message = self.pre_message_mode == PreMessageMode::Post; + let side_effects = RenderSideEffects { + avatar_is_attached, + sync_ids_to_delete, + last_added_location_timestamp, + subject: subject_str, + }; - let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys { - let mut message = add_headers_to_encrypted_part( - message, - &unprotected_headers, - hidden_headers, - protected_headers, - ); + let is_securejoin_message = match &self.loaded { + Loaded::Message { msg, .. } => msg.param.get_cmd() == SystemMessage::SecurejoinMessage, + Loaded::Mdn { .. } => false, + }; + // Disable compression for SecureJoin to ensure + // there are no compression side channels + // leaking information about the tokens. + let should_compress = !is_securejoin_message; + + let shared_secret: Option = match &self.loaded { + Loaded::Message { chat, msg } if should_encrypt_with_broadcast_secret(msg, chat) => { + let secret = load_broadcast_secret(context, chat.id).await?; + if secret.is_none() { + // If there is no shared secret yet + // because this is an old broadcast channel, + // created before we had symmetric encryption, + // we show an error message. + let text = BROADCAST_INCOMPATIBILITY_MSG; + chat::add_info_msg(context, chat.id, text).await?; + bail!(text); + } + secret + } + _ => None, + }; + + if let Some(ref encryption_pubkeys) = self.encryption_pubkeys { // Add gossip headers in chats with multiple recipients let multiple_recipients = encryption_pubkeys.len() > 1 || context.get_config_bool(Config::BccSelf).await?; @@ -1110,10 +1443,10 @@ impl MimeFactory { match &self.loaded { Loaded::Message { chat, msg } => { if !should_hide_recipients(msg, chat) { - for (addr, key) in &encryption_pubkeys { + for (addr, key) in encryption_pubkeys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); - if self.pre_message_mode == PreMessageMode::Post { + if is_post_message { continue; } @@ -1166,10 +1499,10 @@ impl MimeFactory { } .to_string(); - message = message.header( + headers.push(( "Autocrypt-Gossip", - mail_builder::headers::raw::Raw::new(header), - ); + mail_builder::headers::raw::Raw::new(header).into(), + )); context .sql @@ -1188,73 +1521,29 @@ impl MimeFactory { // Never gossip in MDNs. } } + } - // Disable compression for SecureJoin to ensure - // there are no compression side channels - // leaking information about the tokens. - let compress = match &self.loaded { - Loaded::Message { msg, .. } => { - msg.param.get_cmd() != SystemMessage::SecurejoinMessage - } - Loaded::Mdn { .. } => true, - }; - - let shared_secret: Option = match &self.loaded { - Loaded::Message { chat, msg } - if should_encrypt_with_broadcast_secret(msg, chat) => - { - let secret = load_broadcast_secret(context, chat.id).await?; - if secret.is_none() { - // If there is no shared secret yet - // because this is an old broadcast channel, - // created before we had symmetric encryption, - // we show an error message. - let text = BROADCAST_INCOMPATIBILITY_MSG; - chat::add_info_msg(context, chat.id, text).await?; - bail!(text); - } - secret - } - _ => None, - }; - - let encrypted = if let Some(shared_secret) = shared_secret { - let sign = true; - encrypt_helper - .encrypt_symmetrically(context, &shared_secret, message, compress, sign) - .await? - } else { - // Asymmetric encryption + let is_encrypted = self.will_be_encrypted(); - // Use SEIPDv2 if all recipients support it. - let seipd_version = if encryption_pubkeys - .iter() - .all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey)) - { - SeipdVersion::V2 - } else { - SeipdVersion::V1 - }; + let display_name = if is_securejoin_message && !is_encrypted { + // Unencrypted securejoin messages should _not_ include the display name. + "".to_string() + } else { + self.from_displayname.clone() + }; - // Encrypt to self unconditionally, - // even for a single-device setup. - let mut encryption_keyring = vec![encrypt_helper.public_key.clone()]; - encryption_keyring - .extend(encryption_pubkeys.iter().map(|(_addr, key)| (*key).clone())); + let is_mdn = matches!(self.loaded, Loaded::Mdn { .. }); + let should_sign = true; - encrypt_helper - .encrypt( - context, - encryption_keyring, - message, - compress, - seipd_version, - ) - .await? - }; + let HeadersByConfidentiality { + mut unprotected_headers, + hidden_headers, + protected_headers, + } = group_headers_by_confidentiality(headers); - wrap_encrypted_part(encrypted) - } else if matches!(self.loaded, Loaded::Mdn { .. }) { + let message = if self.encryption_pubkeys.is_some() { + add_headers_to_encrypted_part(message, hidden_headers, protected_headers) + } else if is_mdn { // Never add outer multipart/mixed wrapper to MDN // as multipart/report Content-Type is used to recognize MDNs // by Delta Chat receiver and Chatmail servers @@ -1270,6 +1559,10 @@ impl MimeFactory { .fold(message, |message, (header, value)| { message.header(header, value) }); + let message = message.header( + "Message-ID", + mail_builder::headers::message_id::MessageId::new(rfc724_mid.clone()), + ); let message = MimePart::new("multipart/mixed", vec![message]); let message = protected_headers .iter() @@ -1282,24 +1575,26 @@ impl MimeFactory { HashSet::from_iter(protected_headers.iter().map(|(header, _value)| *header)); unprotected_headers.retain(|(header, _value)| !protected.contains(header)); - message + unprotected_headers + .iter() + .cloned() + .fold(message, |message, (header, value)| { + message.header(header, value) + }) }; + let raw_message = part_to_bytes(message); - let MimeFactory { - last_added_location_timestamp, - .. - } = self; - - let message = render_outer_message(unprotected_headers, outer_message); - - Ok(RenderedEmail { - message, - is_encrypted, - last_added_location_timestamp, - sync_ids_to_delete: self.sync_ids_to_delete, + let queued_email = QueuedMail { + raw_message, rfc724_mid, - subject: subject_str, - }) + display_name, + encryption_pubkeys: self.encryption_pubkeys.clone(), + shared_secret, + should_attach_pubkey, + should_sign, + should_compress, + }; + Ok((queued_email, side_effects)) } /// Returns MIME part with a `message.kml` attachment. @@ -1339,12 +1634,12 @@ impl MimeFactory { } async fn render_message( - &mut self, + &self, context: &Context, headers: &mut Vec<(&'static str, HeaderType<'static>)>, grpimage: &Option, is_encrypted: bool, - ) -> Result<(MimePart<'static>, Vec>)> { + ) -> Result { let Loaded::Message { chat, msg } = &self.loaded else { bail!("Attempt to render MDN as a message"); }; @@ -1829,21 +2124,25 @@ impl MimeFactory { parts.push(msg_kml_part); } - if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. }) - && location::is_sending_to_chat(context, msg.chat_id).await? - && let Some((part, timestamp)) = self.get_location_kml_part(context).await? - { - parts.push(part); - self.last_added_location_timestamp = Some(timestamp); - } + let last_added_location_timestamp = + if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. }) + && location::is_sending_to_chat(context, msg.chat_id).await? + && let Some((part, timestamp)) = self.get_location_kml_part(context).await? + { + parts.push(part); + Some(timestamp) + } else { + None + }; + let mut sync_ids_to_delete = None; // 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 { 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())); - self.sync_ids_to_delete = Some(ids.to_string()); + sync_ids_to_delete = Some(ids.to_string()); } else if command == SystemMessage::WebxdcStatusUpdate { let json = msg.param.get(Param::Arg).unwrap_or_default(); parts.push(context.build_status_update_part(json)); @@ -1870,9 +2169,9 @@ impl MimeFactory { } } - self.attach_selfavatar = + let avatar_is_attached = self.attach_selfavatar && self.pre_message_mode != PreMessageMode::Post; - if self.attach_selfavatar { + if avatar_is_attached { match context.get_config(Config::Selfavatar).await? { Some(path) => match build_avatar_file(context, &path).await { Ok(avatar) => headers.push(( @@ -1888,7 +2187,13 @@ impl MimeFactory { } } - Ok((main_part, parts)) + Ok(RenderedMessage { + main_part, + parts, + last_added_location_timestamp, + avatar_is_attached, + sync_ids_to_delete, + }) } /// Render an MDN @@ -1957,23 +2262,6 @@ impl MimeFactory { } } -/// Stores the unprotected headers on the outer message, and renders it. -pub(crate) fn render_outer_message( - unprotected_headers: Vec<(&'static str, HeaderType<'static>)>, - outer_message: MimePart<'static>, -) -> String { - let outer_message = unprotected_headers - .into_iter() - .fold(outer_message, |message, (header, value)| { - message.header(header, value) - }); - - let mut buffer = Vec::new(); - let cursor = Cursor::new(&mut buffer); - outer_message.clone().write_part(cursor).ok(); - String::from_utf8_lossy(&buffer).to_string() -} - /// Takes the encrypted part, wraps it in a MimePart, /// and sets the appropriate Content-Type for the outer message pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> { @@ -1990,38 +2278,18 @@ pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> { fn add_headers_to_encrypted_part( message: MimePart<'static>, - unprotected_headers: &[(&'static str, HeaderType<'static>)], hidden_headers: Vec<(&'static str, HeaderType<'static>)>, protected_headers: Vec<(&'static str, HeaderType<'static>)>, ) -> MimePart<'static> { // Store protected headers in the inner message. - let message = protected_headers - .into_iter() - .fold(message, |message, (header, value)| { - message.header(header, value) - }); - // Add hidden headers to encrypted payload. - let mut message: MimePart<'static> = hidden_headers + let mut message: MimePart<'static> = protected_headers .into_iter() + .chain(hidden_headers) .fold(message, |message, (header, value)| { message.header(header, value) }); - message = unprotected_headers - .iter() - // Structural headers shouldn't be added as "HP-Outer". They are defined in - // . - .filter(|(name, _)| { - !(name.eq_ignore_ascii_case("mime-version") - || name.eq_ignore_ascii_case("content-type") - || name.eq_ignore_ascii_case("content-transfer-encoding") - || name.eq_ignore_ascii_case("content-disposition")) - }) - .fold(message, |message, (name, value)| { - message.header(format!("HP-Outer: {name}"), value.clone()) - }); - // Set the appropriate Content-Type for the inner message for (h, v) in &mut message.headers { if h == "Content-Type" @@ -2076,84 +2344,25 @@ struct HeadersByConfidentiality { /// See [`HeadersByConfidentiality`] for more info. fn group_headers_by_confidentiality( headers: Vec<(&'static str, HeaderType<'static>)>, - from_addr: &str, - timestamp: i64, - is_encrypted: bool, - is_securejoin_message: bool, ) -> HeadersByConfidentiality { let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); - // MIME header . - unprotected_headers.push(( - "MIME-Version", - mail_builder::headers::raw::Raw::new("1.0").into(), - )); - for header @ (original_header_name, _header_value) in &headers { let header_name = original_header_name.to_lowercase(); - if header_name == "message-id" { - unprotected_headers.push(header.clone()); - hidden_headers.push(header.clone()); - } else if is_hidden(&header_name) { - hidden_headers.push(header.clone()); - } else if header_name == "from" { - // Unencrypted securejoin messages should _not_ include the display name: - if is_encrypted || !is_securejoin_message { - protected_headers.push(header.clone()); - } + debug_assert_ne!(header_name, "from"); + debug_assert_ne!(header_name, "message-id"); + debug_assert_ne!(header_name, "autocrypt"); - unprotected_headers.push(( - original_header_name, - Address::new_address(None::<&'static str>, from_addr.to_string()).into(), - )); + if is_hidden(&header_name) { + hidden_headers.push(header.clone()); } else if header_name == "to" { protected_headers.push(header.clone()); - if is_encrypted { - unprotected_headers.push(("To", hidden_recipients().into())); - } else { - unprotected_headers.push(header.clone()); - } + unprotected_headers.push(("To", hidden_recipients().into())); } else if header_name == "chat-broadcast-secret" { - if is_encrypted { - protected_headers.push(header.clone()); - } - } else if is_encrypted && header_name == "date" { protected_headers.push(header.clone()); - - // Randomized date goes to unprotected header. - // - // We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000" - // or omit the header because GMX then fails with - // - // host mx00.emig.gmx.net[212.227.15.9] said: - // 554-Transaction failed - // 554-Reject due to policy restrictions. - // 554 For explanation visit https://postmaster.gmx.net/en/case?... - // (in reply to end of DATA command) - // - // and the explanation page says - // "The time information deviates too much from the actual time". - // - // We also limit the range to 6 days (518400 seconds) - // because with a larger range we got - // error "500 Date header far in the past/future" - // which apparently originates from Symantec Messaging Gateway - // and means the message has a Date that is more - // than 7 days in the past: - // - let timestamp_offset = rand::random_range(0..518400); - let protected_timestamp = timestamp.saturating_sub(timestamp_offset); - let unprotected_date = - chrono::DateTime::::from_timestamp(protected_timestamp, 0) - .unwrap() - .to_rfc2822(); - unprotected_headers.push(( - "Date", - mail_builder::headers::raw::Raw::new(unprotected_date).into(), - )); - } else if is_encrypted { + } else { protected_headers.push(header.clone()); match header_name.as_str() { @@ -2170,8 +2379,6 @@ fn group_headers_by_confidentiality( // Other headers are removed from unprotected part. } } - } else { - unprotected_headers.push(header.clone()) } } HeadersByConfidentiality { @@ -2292,17 +2499,15 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( context: &Context, step: &str, rfc724_mid: &str, - attach_self_pubkey: bool, + should_attach_pubkey: bool, auth: &str, shared_secret: &str, ) -> Result { info!(context, "Sending secure-join message {step:?}."); - let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); + let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join"); - let from_addr = context.get_primary_self_addr().await?; - let from = new_address_with_name("", from_addr.to_string()); - headers.push(("From", from.into())); + let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); let to: Vec> = vec![hidden_recipients()]; headers.push(( @@ -2310,11 +2515,6 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( mail_builder::headers::address::Address::new_list(to.clone()).into(), )); - headers.push(( - "Subject", - mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(), - )); - let timestamp = time(); let date = chrono::DateTime::::from_timestamp(timestamp, 0) .unwrap() @@ -2322,8 +2522,8 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into())); headers.push(( - "Message-ID", - mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(), + "Subject", + mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(), )); // Automatic Response headers @@ -2334,16 +2534,6 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( )); } - let encrypt_helper = EncryptHelper::new(context).await?; - - if attach_self_pubkey { - let aheader = encrypt_helper.get_aheader().to_string(); - headers.push(( - "Autocrypt", - mail_builder::headers::raw::Raw::new(aheader).into(), - )); - } - headers.push(( "Secure-Join", mail_builder::headers::raw::Raw::new(step.to_string()).into(), @@ -2354,46 +2544,57 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( mail_builder::headers::text::Text::new(auth.to_string()).into(), )); - let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join"); - - let is_encrypted = true; - let is_securejoin_message = true; let HeadersByConfidentiality { - unprotected_headers, hidden_headers, protected_headers, - } = group_headers_by_confidentiality( - headers, - &from_addr, - timestamp, - is_encrypted, - is_securejoin_message, - ); - - let outer_message = { - let message = add_headers_to_encrypted_part( - message, - &unprotected_headers, - hidden_headers, - protected_headers, - ); - - // Disable compression for SecureJoin to ensure - // there are no compression side channels - // leaking information about the tokens. - let compress = false; - // Only sign the message if we attach the pubkey. - let sign = attach_self_pubkey; - let encrypted = encrypt_helper - .encrypt_symmetrically(context, shared_secret, message, compress, sign) - .await?; - - wrap_encrypted_part(encrypted) + .. + } = group_headers_by_confidentiality(headers); + + let message = add_headers_to_encrypted_part(message, hidden_headers, protected_headers); + + // Disable compression for SecureJoin to ensure + // there are no compression side channels + // leaking information about the tokens. + let should_compress = false; + + // Only sign the message if we attach the pubkey. + let should_sign = should_attach_pubkey; + + let raw_message = part_to_bytes(message); + + let queued_mail = QueuedMail { + raw_message, + display_name: String::new(), + rfc724_mid: rfc724_mid.to_string(), + encryption_pubkeys: Some(vec![]), + shared_secret: Some(shared_secret.to_string()), + should_attach_pubkey, + should_sign, + should_compress, }; - let message = render_outer_message(unprotected_headers, outer_message); + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; + let side_effects = RenderSideEffects::default(); + + let from_addr = context.get_primary_self_addr().await?; + let rendered_mail = render_queued_mail( + queued_mail, + &public_key, + &secret_key, + from_addr, + side_effects, + )?; + + Ok(rendered_mail.message) +} - Ok(message) +/// Renders MIME part into a vector of bytes. +pub(crate) fn part_to_bytes(message: MimePart<'static>) -> Vec { + let mut raw_message = Vec::new(); + let cursor = Cursor::new(&mut raw_message); + message.write_part(cursor).ok(); + raw_message } #[cfg(test)] diff --git a/src/pgp.rs b/src/pgp.rs index c4a50d5ea3..376dc6daae 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -21,7 +21,6 @@ use pgp::types::{ }; use rand_old::{Rng as _, thread_rng}; use sha2::Sha256; -use tokio::runtime::Handle; use crate::configure::MAX_RELAYS; use crate::key::{DcKey, Fingerprint}; @@ -110,97 +109,95 @@ pub enum SeipdVersion { /// Encrypts `plain` text using `public_keys_for_encryption` /// and signs it using `private_key_for_signing`. #[expect(clippy::arithmetic_side_effects)] -pub async fn pk_encrypt( +pub fn pk_encrypt( plain: Vec, public_keys_for_encryption: Vec, private_key_for_signing: SignedSecretKey, compress: bool, seipd_version: SeipdVersion, ) -> Result { - Handle::current() - .spawn_blocking(move || { - let mut rng = thread_rng(); + tokio::task::block_in_place(|| { + let mut rng = thread_rng(); - let pkeys = public_keys_for_encryption - .iter() - .filter_map(select_pk_for_encryption); - let subpkts = { - let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1); - hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime( - pgp::types::Timestamp::now(), - ))?); - for key in &public_keys_for_encryption { - let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint()); - let subpkt = match private_key_for_signing.version() < KeyVersion::V6 { - true => Subpacket::regular(data)?, - false => Subpacket::critical(data)?, - }; - hashed.push(subpkt); - } - hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint( - private_key_for_signing.fingerprint(), + let pkeys = public_keys_for_encryption + .iter() + .filter_map(select_pk_for_encryption); + let subpkts = { + let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1); + hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime( + pgp::types::Timestamp::now(), + ))?); + for key in &public_keys_for_encryption { + let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint()); + let subpkt = match private_key_for_signing.version() < KeyVersion::V6 { + true => Subpacket::regular(data)?, + false => Subpacket::critical(data)?, + }; + hashed.push(subpkt); + } + hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint( + private_key_for_signing.fingerprint(), + ))?); + let mut unhashed = vec![]; + if private_key_for_signing.version() <= KeyVersion::V4 { + unhashed.push(Subpacket::regular(SubpacketData::IssuerKeyId( + private_key_for_signing.legacy_key_id(), ))?); - let mut unhashed = vec![]; - if private_key_for_signing.version() <= KeyVersion::V4 { - unhashed.push(Subpacket::regular(SubpacketData::IssuerKeyId( - private_key_for_signing.legacy_key_id(), - ))?); - } - SubpacketConfig::UserDefined { hashed, unhashed } - }; - - let msg = MessageBuilder::from_bytes("", plain); - let encoded_msg = match seipd_version { - SeipdVersion::V1 => { - let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); - - for pkey in pkeys { - msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; - } + } + SubpacketConfig::UserDefined { hashed, unhashed } + }; - let hash_algorithm = private_key_for_signing.hash_alg(); - msg.sign_with_subpackets( - &*private_key_for_signing, - Password::empty(), - hash_algorithm, - subpkts, - ); - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + let msg = MessageBuilder::from_bytes("", plain); + let encoded_msg = match seipd_version { + SeipdVersion::V1 => { + let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); - msg.to_armored_string(&mut rng, Default::default())? + for pkey in pkeys { + msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; } - SeipdVersion::V2 => { - let mut msg = msg.seipd_v2( - &mut rng, - SYMMETRIC_KEY_ALGORITHM, - AeadAlgorithm::Ocb, - ChunkSize::C8KiB, - ); - for pkey in pkeys { - msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; - } + let hash_algorithm = private_key_for_signing.hash_alg(); + msg.sign_with_subpackets( + &*private_key_for_signing, + Password::empty(), + hash_algorithm, + subpkts, + ); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); + } - let hash_algorithm = private_key_for_signing.hash_alg(); - msg.sign_with_subpackets( - &*private_key_for_signing, - Password::empty(), - hash_algorithm, - subpkts, - ); - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + msg.to_armored_string(&mut rng, Default::default())? + } + SeipdVersion::V2 => { + let mut msg = msg.seipd_v2( + &mut rng, + SYMMETRIC_KEY_ALGORITHM, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + + for pkey in pkeys { + msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; + } - msg.to_armored_string(&mut rng, Default::default())? + let hash_algorithm = private_key_for_signing.hash_alg(); + msg.sign_with_subpackets( + &*private_key_for_signing, + Password::empty(), + hash_algorithm, + subpkts, + ); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); } - }; - Ok(encoded_msg) - }) - .await? + msg.to_armored_string(&mut rng, Default::default())? + } + }; + + Ok(encoded_msg) + }) } /// Returns fingerprints @@ -261,35 +258,37 @@ pub fn symm_encrypt_message( shared_secret: String, compress: bool, ) -> Result { - let shared_secret = Password::from(shared_secret); + tokio::task::block_in_place(|| { + let shared_secret = Password::from(shared_secret); - let msg = MessageBuilder::from_bytes("", plain); - let mut rng = thread_rng(); - let mut salt = [0u8; 8]; - rng.fill(&mut salt[..]); - let s2k = StringToKey::Salted { - hash_alg: HashAlgorithm::default(), - salt, - }; - let mut msg = msg.seipd_v2( - &mut rng, - SYMMETRIC_KEY_ALGORITHM, - AeadAlgorithm::Ocb, - ChunkSize::C8KiB, - ); - msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?; + let msg = MessageBuilder::from_bytes("", plain); + let mut rng = thread_rng(); + let mut salt = [0u8; 8]; + rng.fill(&mut salt[..]); + let s2k = StringToKey::Salted { + hash_alg: HashAlgorithm::default(), + salt, + }; + let mut msg = msg.seipd_v2( + &mut rng, + SYMMETRIC_KEY_ALGORITHM, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?; - if let Some(private_key_for_signing) = private_key_for_signing.as_deref() { - let hash_algorithm = private_key_for_signing.hash_alg(); - msg.sign(private_key_for_signing, Password::empty(), hash_algorithm); - } - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + if let Some(private_key_for_signing) = private_key_for_signing.as_deref() { + let hash_algorithm = private_key_for_signing.hash_alg(); + msg.sign(private_key_for_signing, Password::empty(), hash_algorithm); + } + if compress { + msg.compression(CompressionAlgorithm::ZLIB); + } - let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?; + let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?; - Ok(encoded_msg) + Ok(encoded_msg) + }) } /// Merges and minimizes OpenPGP certificates. @@ -492,7 +491,7 @@ mod tests { config::Config, decrypt, key::{load_self_public_key, self_fingerprint, store_self_keypair}, - mimefactory::{render_outer_message, wrap_encrypted_part}, + mimefactory::{part_to_bytes, wrap_encrypted_part}, test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair}, token, }; @@ -518,8 +517,8 @@ mod tests { store_self_keypair(t, secret_key).await?; let mime_message = wrap_encrypted_part(bytes.try_into().unwrap()); - let rendered = render_outer_message(vec![], mime_message); - let parsed = mailparse::parse_mail(rendered.as_bytes())?; + let rendered = part_to_bytes(mime_message); + let parsed = mailparse::parse_mail(&rendered)?; let (decrypted, _fp) = decrypt::decrypt(t, &parsed).await?.unwrap(); Ok(decrypted) } @@ -592,7 +591,6 @@ mod tests { compress, SeipdVersion::V2, ) - .await .unwrap() }) .await @@ -783,8 +781,7 @@ mod tests { KEYS.alice_secret.clone(), compress, SeipdVersion::V2, - ) - .await?; + )?; // Trying to decrypt it should fail with an OK error message: let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; diff --git a/src/reaction.rs b/src/reaction.rs index 2b4752ca15..09ec92ad9d 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -1170,8 +1170,7 @@ Content-Transfer-Encoding: base64\r alice_secret_key, compress, SeipdVersion::V2, - ) - .await?; + )?; let boundary = "boundary123"; let rcvd_mail = format!( diff --git a/src/test_utils.rs b/src/test_utils.rs index f3bb9ef8b3..b1c52a5e54 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -22,6 +22,7 @@ use tokio::runtime::Handle; use tokio::{fs, task}; use uuid::Uuid; +use crate::aheader::{Aheader, EncryptPreference}; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, MessageListOptions, add_to_chat_contacts_table, create_group, }; @@ -33,7 +34,6 @@ use crate::contact::{ Contact, ContactId, Modifier, Origin, import_vcard, make_vcard, mark_contact_id_as_verified, }; use crate::context::Context; -use crate::e2ee::EncryptHelper; use crate::events::{Event, EventEmitter, EventType, Events}; use crate::key::{self, DcKey, self_fingerprint}; use crate::log::warn; @@ -1240,8 +1240,15 @@ pub async fn encrypt_raw_message( receivers: &[&TestContext], payload: &[u8], ) -> Result { - let encryption_helper = EncryptHelper::new(context).await?; - let mut encryption_keyring = vec![encryption_helper.public_key.clone()]; + let public_key = key::load_self_public_key(context).await?; + let aheader = Aheader { + addr: context.get_primary_self_addr().await?, + public_key: public_key.clone(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false, + }; + + let mut encryption_keyring = vec![public_key.clone()]; for receiver in receivers { encryption_keyring.push(key::load_self_public_key(receiver).await?); @@ -1250,18 +1257,17 @@ pub async fn encrypt_raw_message( let from = context.get_primary_self_addr().await?; let compress = false; - let mut cleartext = format!("Autocrypt: {}", encryption_helper.get_aheader()).into_bytes(); + let mut cleartext = format!("Autocrypt: {aheader}").into_bytes(); cleartext.extend_from_slice(b"\r\n"); cleartext.extend_from_slice(payload); - let encrypted_payload = encryption_helper - .encrypt_raw( - context, - encryption_keyring, - cleartext, - compress, - SeipdVersion::V2, - ) - .await?; + let sign_key = key::load_self_secret_key(context).await?; + let encrypted_payload = crate::pgp::pk_encrypt( + cleartext, + encryption_keyring, + sign_key, + compress, + SeipdVersion::V2, + )?; let boundary = Uuid::new_v4(); let res = format!( From 3891ceb805ae5d0af433a0f9b27b2067519dd463 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 4 Aug 2026 13:34:11 +0200 Subject: [PATCH 2/2] test: Add test for unencrypted headers (#8538) This adds a test for the unencrypted headers, because https://github.com/chatmail/core/pull/8345/ changes how these are rendered, and so far we didn't have any tests for them. --- src/mimefactory/mimefactory_tests.rs | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/mimefactory/mimefactory_tests.rs b/src/mimefactory/mimefactory_tests.rs index 5825234611..e6c8909e62 100644 --- a/src/mimefactory/mimefactory_tests.rs +++ b/src/mimefactory/mimefactory_tests.rs @@ -3,6 +3,8 @@ use mail_builder::headers::Header; use mailparse::{MailHeaderMap, addrparse_header}; use pgp::armor; use pgp::packet::{Packet, PacketParser}; +use pretty_assertions::assert_eq; +use regex::regex; use std::io::BufReader; use std::str; use std::time::Duration; @@ -1013,3 +1015,68 @@ END:VCARD"; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_render_outer_headers() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat_id = alice.create_chat_id(bob).await; + let sent = alice.send_text(chat_id, "Hello!").await; + + let (unencrypted, _encrypted) = sent + .payload() + .split_once("-----BEGIN PGP MESSAGE-----") + .unwrap(); + + // Normalize the parts of the message that vary between runs + // (MIME boundary, Date, Message-ID) + let boundary = unencrypted + .split_once("boundary=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(b, _)| b) + .unwrap_or_default(); + let unencrypted = unencrypted.replace(boundary, "BOUNDARY"); + + let rfc724_mid = sent.load_from_db().await.rfc724_mid; + let unencrypted = unencrypted.replace(&rfc724_mid, "MESSAGE_ID@localhost"); + + let unencrypted = regex!(r"Date:[^\r\n]*") + .replace(&unencrypted, "Date: DATE") + .to_string(); + + let expected = r#"From: +Date: DATE +Message-ID: +MIME-Version: 1.0 +To: "hidden-recipients": ; +Subject: [...] +Chat-Version: 1.0 +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="BOUNDARY" + + +--BOUNDARY +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Version: 1 + +--BOUNDARY +Content-Type: application/octet-stream; charset="utf-8" +Content-Transfer-Encoding: 7bit + +"# + .replace("\n", "\r\n"); + assert_eq!( + unencrypted, expected, + "---------------- Actual: ---------------- +{unencrypted} +----------------------------------------- +actual (debug print): {unencrypted:?} +expected (debug print): {expected:?}" + ); + + Ok(()) +}