diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 00b42884b..41e5eface 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -116,34 +116,6 @@ pub struct RenderedEmail { pub subject: String, } -#[derive(Debug, Clone, Default)] -struct MessageHeaders { - /// Opportunistically protected headers. - /// - /// These headers are placed into encrypted part *if* the message is encrypted. Place headers - /// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the - /// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here. - /// - /// If the message is not encrypted, these headers are placed into IMF header section, so make - /// sure that the message will be encrypted if you place any sensitive information here. - pub protected: Vec
, - - /// Headers that must go into IMF header section. - /// - /// These are standard headers such as Date, In-Reply-To, References, which cannot be placed - /// anywhere else according to the standard. Placing headers here also allows them to be fetched - /// individually over IMAP without downloading the message body. This is why Chat-Version is - /// placed here. - pub unprotected: Vec
, - - /// Headers that MUST NOT go into IMF header section. - /// - /// These are large headers which may hit the header section size limit on the server, such as - /// Chat-User-Avatar with a base64-encoded image inside. Also there are headers duplicated here - /// that servers mess up with in the IMF header section, like Message-ID. - pub hidden: Vec
, -} - impl MimeFactory { pub async fn from_msg(context: &Context, msg: Message) -> Result { let chat = Chat::load_from_db(context, msg.chat_id).await?; @@ -507,7 +479,7 @@ impl MimeFactory { /// Consumes a `MimeFactory` and renders it into a message which is then stored in /// `smtp`-table to be used by the SMTP loop pub async fn render(mut self, context: &Context) -> Result { - let mut headers: MessageHeaders = Default::default(); + let mut headers = Vec::
::new(); let from = Address::new_mailbox_with_name( self.from_displayname.to_string(), @@ -562,18 +534,14 @@ impl MimeFactory { // Start with Internet Message Format headers in the order of the standard example // . let from_header = Header::new_with_value("From".into(), vec![from]).unwrap(); - headers.protected.push(from_header.clone()); + headers.push(from_header.clone()); if let Some(sender_displayname) = &self.sender_displayname { let sender = Address::new_mailbox_with_name(sender_displayname.clone(), self.from_addr.clone()); - headers - .unprotected - .push(Header::new_with_value("Sender".into(), vec![sender]).unwrap()); + headers.push(Header::new_with_value("Sender".into(), vec![sender]).unwrap()); } - headers - .protected - .push(Header::new_with_value("To".into(), to.clone()).unwrap()); + headers.push(Header::new_with_value("To".into(), to.clone()).unwrap()); let subject_str = self.subject_str(context).await?; let encoded_subject = if subject_str @@ -586,14 +554,12 @@ impl MimeFactory { } else { encode_words(&subject_str) }; - headers - .protected - .push(Header::new("Subject".into(), encoded_subject)); + headers.push(Header::new("Subject".into(), encoded_subject)); let date = chrono::DateTime::::from_timestamp(self.timestamp, 0) .unwrap() .to_rfc2822(); - headers.unprotected.push(Header::new("Date".into(), date)); + headers.push(Header::new("Date".into(), date)); let rfc724_mid = match &self.loaded { Loaded::Message { msg, .. } => msg.rfc724_mid.clone(), @@ -601,29 +567,24 @@ impl MimeFactory { }; let rfc724_mid_headervalue = render_rfc724_mid(&rfc724_mid); let rfc724_mid_header = Header::new("Message-ID".into(), rfc724_mid_headervalue); - headers.unprotected.push(rfc724_mid_header.clone()); - headers.hidden.push(rfc724_mid_header); + headers.push(rfc724_mid_header); // Reply headers as in . if !self.in_reply_to.is_empty() { - headers - .unprotected - .push(Header::new("In-Reply-To".into(), self.in_reply_to.clone())); + headers.push(Header::new("In-Reply-To".into(), self.in_reply_to.clone())); } if !self.references.is_empty() { - headers - .unprotected - .push(Header::new("References".into(), self.references.clone())); + headers.push(Header::new("References".into(), self.references.clone())); } // Automatic Response headers if let Loaded::Mdn { .. } = self.loaded { - headers.unprotected.push(Header::new( + headers.push(Header::new( "Auto-Submitted".to_string(), "auto-replied".to_string(), )); } else if context.get_config_bool(Config::Bot).await? { - headers.unprotected.push(Header::new( + headers.push(Header::new( "Auto-Submitted".to_string(), "auto-generated".to_string(), )); @@ -632,7 +593,7 @@ impl MimeFactory { if let Loaded::Message { chat, .. } = &self.loaded { if chat.typ == Chattype::Broadcast { let encoded_chat_name = encode_words(&chat.name); - headers.protected.push(Header::new( + headers.push(Header::new( "List-ID".into(), format!("{encoded_chat_name} <{}>", chat.grpid), )); @@ -640,15 +601,13 @@ impl MimeFactory { } // Non-standard headers. - headers - .unprotected - .push(Header::new("Chat-Version".to_string(), "1.0".to_string())); + headers.push(Header::new("Chat-Version".to_string(), "1.0".to_string())); if self.req_mdn { // we use "Chat-Disposition-Notification-To" // because replies to "Disposition-Notification-To" are weird in many cases // eg. are just freetext and/or do not follow any standard. - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Disposition-Notification-To".into(), self.from_addr.clone(), )); @@ -664,9 +623,7 @@ impl MimeFactory { if !skip_autocrypt { // unless determined otherwise we add the Autocrypt header let aheader = encrypt_helper.get_aheader().to_string(); - headers - .unprotected - .push(Header::new("Autocrypt".into(), aheader)); + headers.push(Header::new("Autocrypt".into(), aheader)); } // Add ephemeral timer for non-MDN messages. @@ -675,71 +632,24 @@ impl MimeFactory { if let Loaded::Message { msg, .. } = &self.loaded { let ephemeral_timer = msg.chat_id.get_ephemeral_timer(context).await?; if let EphemeralTimer::Enabled { duration } = ephemeral_timer { - headers.protected.push(Header::new( + headers.push(Header::new( "Ephemeral-Timer".to_string(), duration.to_string(), )); } } - // MIME header . - // Content-Type - headers - .unprotected - .push(Header::new("MIME-Version".into(), "1.0".into())); - let mut is_gossiped = false; let peerstates = self.peerstates_for_recipients(context).await?; let should_encrypt = encrypt_helper.should_encrypt(context, e2ee_guaranteed, &peerstates)?; let is_encrypted = should_encrypt && !force_plaintext; - - if is_encrypted { - headers.unprotected.insert( - 0, - Header::new_with_value( - "To".into(), - to.into_iter() - .map(|header| match header { - Address::Mailbox(mb) => Address::Mailbox(Mailbox { - address: mb.address, - name: None, - }), - Address::Group(name, participants) => Address::new_group( - name, - participants - .into_iter() - .map(|mb| Mailbox { - address: mb.address, - name: None, - }) - .collect(), - ), - }) - .collect::>(), - ) - .unwrap(), - ); - } - let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { msg.param.get_cmd() == SystemMessage::SecurejoinMessage } else { false }; - if is_encrypted && verified || is_securejoin_message { - headers.unprotected.insert( - 0, - Header::new_with_value( - "From".into(), - vec![Address::new_mailbox(self.from_addr.clone())], - ) - .unwrap(), - ); - } else { - headers.unprotected.insert(0, from_header); - } let message = match &self.loaded { Loaded::Message { msg, .. } => { @@ -782,16 +692,130 @@ impl MimeFactory { "protected-headers=\"v1\"".to_string(), ) }; + + // Split headers based on header confidentiality policy. + + // Headers that must go into IMF header section. + // + // These are standard headers such as Date, In-Reply-To, References, which cannot be placed + // anywhere else according to the standard. Placing headers here also allows them to be fetched + // individually over IMAP without downloading the message body. This is why Chat-Version is + // placed here. + let mut unprotected_headers: Vec
= Vec::new(); + + // Headers that MUST NOT go into IMF header section. + // + // These are large headers which may hit the header section size limit on the server, such as + // Chat-User-Avatar with a base64-encoded image inside. Also there are headers duplicated here + // that servers mess up with in the IMF header section, like Message-ID. + // + // The header should be hidden from MTA + // by moving it either into protected part + // in case of encrypted mails + // or unprotected MIME preamble in case of unencrypted mails. + let mut hidden_headers: Vec
= Vec::new(); + + // Opportunistically protected headers. + // + // These headers are placed into encrypted part *if* the message is encrypted. Place headers + // which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the + // message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here. + // + // If the message is not encrypted, these headers are placed into IMF header section, so make + // sure that the message will be encrypted if you place any sensitive information here. + let mut protected_headers: Vec
= Vec::new(); + + // MIME header . + unprotected_headers.push(Header::new("MIME-Version".into(), "1.0".into())); + for header in headers { + let header_name = header.name.to_lowercase(); + if header_name == "message-id" { + unprotected_headers.push(header.clone()); + hidden_headers.push(header); + } else if header_name == "chat-user-avatar" { + hidden_headers.push(header); + } else if header_name == "autocrypt" { + unprotected_headers.push(header.clone()); + } else if header_name == "from" { + protected_headers.push(header.clone()); + if is_encrypted && verified || is_securejoin_message { + unprotected_headers.push( + Header::new_with_value( + header.name, + vec![Address::new_mailbox(self.from_addr.clone())], + ) + .unwrap(), + ); + } else { + unprotected_headers.push(header); + } + } else if header_name == "to" { + protected_headers.push(header.clone()); + if is_encrypted { + unprotected_headers.push( + Header::new_with_value( + header.name, + to.clone() + .into_iter() + .map(|header| match header { + Address::Mailbox(mb) => Address::Mailbox(Mailbox { + address: mb.address, + name: None, + }), + Address::Group(name, participants) => Address::new_group( + name, + participants + .into_iter() + .map(|mb| Mailbox { + address: mb.address, + name: None, + }) + .collect(), + ), + }) + .collect::>(), + ) + .unwrap(), + ); + } else { + unprotected_headers.push(header); + } + } else if is_encrypted { + protected_headers.push(header.clone()); + + match header_name.as_str() { + "subject" => { + unprotected_headers.push(Header::new(header.name, "...".to_string())); + } + "date" + | "in-reply-to" + | "references" + | "auto-submitted" + | "chat-version" + | "autocrypt-setup-message" => { + unprotected_headers.push(header); + } + _ => { + // Other headers are removed from unprotected part. + } + } + } else { + // Copy the header to the protected headers + // in case of signed-only message. + // If the message is not signed, this value will not be used. + protected_headers.push(header.clone()); + unprotected_headers.push(header) + } + } + let outer_message = if is_encrypted { // Store protected headers in the inner message. - let message = headers - .protected + let message = protected_headers .into_iter() .fold(message, |message, header| message.header(header)); // Add hidden headers to encrypted payload. - let mut message = headers - .hidden + let mut message = hidden_headers .into_iter() .fold(message, |message, header| message.header(header)); @@ -867,7 +891,6 @@ impl MimeFactory { .body(encrypted) .build(), ) - .header(("Subject".to_string(), "...".to_string())) } else if matches!(self.loaded, Loaded::Mdn { .. }) { // Never add outer multipart/mixed wrapper to MDN // as multipart/report Content-Type is used to recognize MDNs @@ -877,39 +900,23 @@ impl MimeFactory { // that normally only allows encrypted mails. // Hidden headers are dropped. - - // Store protected headers in the outer message. - let message = headers - .protected - .iter() - .fold(message, |message, header| message.header(header.clone())); - - let protected: HashSet
= HashSet::from_iter(headers.protected.into_iter()); - for h in headers.unprotected.split_off(0) { - if !protected.contains(&h) { - headers.unprotected.push(h); - } - } - message } else { - let message = headers - .hidden + let message = hidden_headers .into_iter() .fold(message, |message, header| message.header(header)); let message = PartBuilder::new() .message_type(MimeMultipartType::Mixed) .child(message.build()); - let message = headers - .protected + let message = protected_headers .iter() .fold(message, |message, header| message.header(header.clone())); if skip_autocrypt || !context.get_config_bool(Config::SignUnencrypted).await? { - let protected: HashSet
= HashSet::from_iter(headers.protected.into_iter()); - for h in headers.unprotected.split_off(0) { + let protected: HashSet
= HashSet::from_iter(protected_headers.into_iter()); + for h in unprotected_headers.split_off(0) { if !protected.contains(&h) { - headers.unprotected.push(h); + unprotected_headers.push(h); } } message @@ -938,8 +945,7 @@ impl MimeFactory { }; // Store the unprotected headers on the outer message. - let outer_message = headers - .unprotected + let outer_message = unprotected_headers .into_iter() .fold(outer_message, |message, header| message.header(header)); @@ -1036,7 +1042,7 @@ impl MimeFactory { async fn render_message( &mut self, context: &Context, - headers: &mut MessageHeaders, + headers: &mut Vec
, grpimage: &Option, is_encrypted: bool, ) -> Result<(PartBuilder, Vec)> { @@ -1056,23 +1062,17 @@ impl MimeFactory { Chattype::Broadcast => false, }; if chat.is_protected() && send_verified_headers { - headers - .protected - .push(Header::new("Chat-Verified".to_string(), "1".to_string())); + headers.push(Header::new("Chat-Verified".to_string(), "1".to_string())); } if chat.typ == Chattype::Group { // Send group ID unless it is an ad hoc group that has no ID. if !chat.grpid.is_empty() { - headers - .protected - .push(Header::new("Chat-Group-ID".into(), chat.grpid.clone())); + headers.push(Header::new("Chat-Group-ID".into(), chat.grpid.clone())); } let encoded = encode_words(&chat.name); - headers - .protected - .push(Header::new("Chat-Group-Name".into(), encoded)); + headers.push(Header::new("Chat-Group-Name".into(), encoded)); match command { SystemMessage::MemberRemovedFromGroup => { @@ -1091,7 +1091,7 @@ impl MimeFactory { }; if !email_to_remove.is_empty() { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Group-Member-Removed".into(), email_to_remove.into(), )); @@ -1103,7 +1103,7 @@ impl MimeFactory { Some(stock_str::msg_add_member_remote(context, email_to_add).await); if !email_to_add.is_empty() { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Group-Member-Added".into(), email_to_add.into(), )); @@ -1113,7 +1113,7 @@ impl MimeFactory { context, "Sending secure-join message {:?}.", "vg-member-added", ); - headers.protected.push(Header::new( + headers.push(Header::new( "Secure-Join".to_string(), "vg-member-added".to_string(), )); @@ -1121,18 +1121,18 @@ impl MimeFactory { } SystemMessage::GroupNameChanged => { let old_name = msg.param.get(Param::Arg).unwrap_or_default(); - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Group-Name-Changed".into(), maybe_encode_words(old_name), )); } SystemMessage::GroupImageChanged => { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".to_string(), "group-avatar-changed".to_string(), )); if grpimage.is_none() { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Group-Avatar".to_string(), "0".to_string(), )); @@ -1144,13 +1144,13 @@ impl MimeFactory { match command { SystemMessage::LocationStreamingEnabled => { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".into(), "location-streaming-enabled".into(), )); } SystemMessage::EphemeralTimerChanged => { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".to_string(), "ephemeral-timer-changed".to_string(), )); @@ -1166,15 +1166,13 @@ impl MimeFactory { // Adding this header without encryption leaks some // information about the message contents, but it can // already be easily guessed from message timing and size. - headers.unprotected.push(Header::new( + headers.push(Header::new( "Auto-Submitted".to_string(), "auto-generated".to_string(), )); } SystemMessage::AutocryptSetupMessage => { - headers - .unprotected - .push(Header::new("Autocrypt-Setup-Message".into(), "v1".into())); + headers.push(Header::new("Autocrypt-Setup-Message".into(), "v1".into())); placeholdertext = Some(stock_str::ac_setup_msg_body(context).await); } @@ -1182,13 +1180,11 @@ impl MimeFactory { let step = msg.param.get(Param::Arg).unwrap_or_default(); if !step.is_empty() { info!(context, "Sending secure-join message {step:?}."); - headers - .protected - .push(Header::new("Secure-Join".into(), step.into())); + headers.push(Header::new("Secure-Join".into(), step.into())); let param2 = msg.param.get(Param::Arg2).unwrap_or_default(); if !param2.is_empty() { - headers.protected.push(Header::new( + headers.push(Header::new( if step == "vg-request-with-auth" || step == "vc-request-with-auth" { "Secure-Join-Auth".into() } else { @@ -1200,32 +1196,30 @@ impl MimeFactory { let fingerprint = msg.param.get(Param::Arg3).unwrap_or_default(); if !fingerprint.is_empty() { - headers.protected.push(Header::new( + headers.push(Header::new( "Secure-Join-Fingerprint".into(), fingerprint.into(), )); } if let Some(id) = msg.param.get(Param::Arg4) { - headers - .protected - .push(Header::new("Secure-Join-Group".into(), id.into())); + headers.push(Header::new("Secure-Join-Group".into(), id.into())); }; } } SystemMessage::ChatProtectionEnabled => { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".to_string(), "protection-enabled".to_string(), )); } SystemMessage::ChatProtectionDisabled => { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".to_string(), "protection-disabled".to_string(), )); } SystemMessage::IrohNodeAddr => { - headers.protected.push(Header::new( + headers.push(Header::new( HeaderDef::IrohNodeAddr.get_headername().to_string(), serde_json::to_string( &context @@ -1244,22 +1238,20 @@ impl MimeFactory { let avatar = build_avatar_file(context, grpimage) .await .context("Cannot attach group image")?; - headers.hidden.push(Header::new( + headers.push(Header::new( "Chat-Group-Avatar".into(), format!("base64:{avatar}"), )); } if msg.viewtype == Viewtype::Sticker { - headers - .protected - .push(Header::new("Chat-Content".into(), "sticker".into())); + headers.push(Header::new("Chat-Content".into(), "sticker".into())); } else if msg.viewtype == Viewtype::VideochatInvitation { - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Content".into(), "videochat-invitation".into(), )); - headers.protected.push(Header::new( + headers.push(Header::new( "Chat-Webrtc-Room".into(), msg.param.get(Param::WebrtcRoom).unwrap_or_default().into(), )); @@ -1270,16 +1262,12 @@ impl MimeFactory { || msg.viewtype == Viewtype::Video { if msg.viewtype == Viewtype::Voice { - headers - .protected - .push(Header::new("Chat-Voice-Message".into(), "1".into())); + headers.push(Header::new("Chat-Voice-Message".into(), "1".into())); } let duration_ms = msg.param.get_int(Param::Duration).unwrap_or_default(); if duration_ms > 0 { let dur = duration_ms.to_string(); - headers - .protected - .push(Header::new("Chat-Duration".into(), dur)); + headers.push(Header::new("Chat-Duration".into(), dur)); } } @@ -1392,9 +1380,7 @@ impl MimeFactory { parts.push(context.build_status_update_part(json)); } else if msg.viewtype == Viewtype::Webxdc { let topic = peer_channels::create_random_topic(); - headers - .protected - .push(create_iroh_header(context, topic, msg.id).await?); + headers.push(create_iroh_header(context, topic, msg.id).await?); if let Some(json) = context .render_webxdc_status_update_object(msg.id, None) .await? @@ -1406,15 +1392,13 @@ impl MimeFactory { if self.attach_selfavatar { match context.get_config(Config::Selfavatar).await? { Some(path) => match build_avatar_file(context, &path).await { - Ok(avatar) => headers.hidden.push(Header::new( + Ok(avatar) => headers.push(Header::new( "Chat-User-Avatar".into(), format!("base64:{avatar}"), )), Err(err) => warn!(context, "mimefactory: cannot attach selfavatar: {}", err), }, - None => headers - .protected - .push(Header::new("Chat-User-Avatar".into(), "0".into())), + None => headers.push(Header::new("Chat-User-Avatar".into(), "0".into())), } } @@ -2363,7 +2347,8 @@ mod tests { .await .unwrap(); - // send message to bob: that should get multipart/mixed because of the avatar moved to inner header; + // send message to bob: that should get multipart/signed. + // `Subject:` is protected by copying it. // make sure, `Subject:` stays in the outer header (imf header) let mut msg = Message::new(Viewtype::Text); msg.set_text("this is the text!".to_string()); @@ -2375,7 +2360,7 @@ mod tests { assert_eq!(part.match_indices("multipart/signed").count(), 1); assert_eq!(part.match_indices("From:").count(), 1); assert_eq!(part.match_indices("Message-ID:").count(), 1); - assert_eq!(part.match_indices("Subject:").count(), 0); + assert_eq!(part.match_indices("Subject:").count(), 1); assert_eq!(part.match_indices("Autocrypt:").count(), 1); assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0); @@ -2423,7 +2408,7 @@ mod tests { assert_eq!(part.match_indices("multipart/signed").count(), 1); assert_eq!(part.match_indices("From:").count(), 1); assert_eq!(part.match_indices("Message-ID:").count(), 1); - assert_eq!(part.match_indices("Subject:").count(), 0); + assert_eq!(part.match_indices("Subject:").count(), 1); assert_eq!(part.match_indices("Autocrypt:").count(), 1); assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);