From d24aaf83e456fce6278aa483d2c24b6296d66ed6 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Mon, 8 Jun 2026 10:58:25 +0200 Subject: [PATCH] Add MIME support - Wrap encrypted mails into a mime structure - Add 'protected headers' --- src/common/crypto.rs | 2 +- src/config.rs | 2 +- src/daemon/mod.rs | 52 ++++++++++---- src/types/cheader.rs | 128 +++++++++++++++++++++++++++++++++ src/types/husk_context.rs | 9 ++- src/types/mail_context.rs | 130 +++++++++++++++++++++++++++++++++- src/types/mime/header.rs | 59 +++++++++++++++ src/types/mime/header_line.rs | 75 ++++++++++++++++++++ src/types/mime/mod.rs | 9 +++ src/types/mime/multi_part.rs | 124 ++++++++++++++++++++++++++++++++ src/types/mime/part.rs | 63 ++++++++++++++++ src/types/mod.rs | 3 +- 12 files changed, 638 insertions(+), 18 deletions(-) create mode 100644 src/types/cheader.rs create mode 100644 src/types/mime/header.rs create mode 100644 src/types/mime/header_line.rs create mode 100644 src/types/mime/mod.rs create mode 100644 src/types/mime/multi_part.rs create mode 100644 src/types/mime/part.rs diff --git a/src/common/crypto.rs b/src/common/crypto.rs index 51e9aa8..575d5f7 100644 --- a/src/common/crypto.rs +++ b/src/common/crypto.rs @@ -389,7 +389,7 @@ pub fn is_encrypted(body: &Bytes) -> bool { } /// Encrypt a message for `recipients`. -pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec) +pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec) -> anyhow::Result { let mut keys = Vec::new(); diff --git a/src/config.rs b/src/config.rs index 46311ba..75c7947 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,7 +40,7 @@ impl HuskConfig { Err(HuskError::ConfigFileOpenError(format!( "{} - {}", path, - e.to_string())).into()) + e)).into()) } } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 2dd241b..e071ff3 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -22,7 +22,7 @@ use crate::types::husk_context::HuskContext; use crate::config::{HuskConfig, HuskConfigContainer}; use crate::mail; use crate::common::crypto; -use crate::types::mail_context::ProtectionPossibility; +use crate::types::mail_context::{MailContext, ProtectionPossibility}; pub struct Daemon { } @@ -149,25 +149,30 @@ impl Daemon { log::debug!("DATA"); if let Some(ref mut _context) = cx.data { - // XXX: check for early accepting - // if there are no recipients with authenticated certificates - // tell the MTA that this milter is done and the mail can be further - // processed. Status::Continue } else { Status::Tempfail } } - async fn handle_header(_cx: &mut Context>, name: CString, value: CString) -> Status { - log::debug!("HEADER: {name:?} = {value:?}"); - - Status::Continue + async fn handle_header(cx: &mut Context>, key: CString, value: CString) -> Status { + log::debug!("HEADER: {key:?} = {value:?}"); + if let Some(ref mut context) = cx.data { + context.add_to_header(key, value); + Status::Continue + } else { + Status::Tempfail + } } async fn handle_eoh(_cx: &mut Context>) -> Status { log::debug!("EOH"); + // XXX: check for early accepting + // if there are no recipients with authenticated certificates + // tell the MTA that this milter is done and the mail can be further + // processed. + Status::Continue } @@ -198,7 +203,6 @@ impl Daemon { return Status::Accept; } - // possible protection // none -> accept // partial -> accept (later: split) @@ -207,11 +211,35 @@ impl Daemon { let protection = ProtectionPossibility::from(&context.mail); if protection == ProtectionPossibility::Full { + + let inner_mail = context.mail.generate_inner_mail(body); + // if an error occures the body is not exchanged - match crypto::encrypt(context, &body, &context.mail.recipients) { + match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) { Ok(encrypted) => { - if cx.actions.replace_body(&encrypted).await.is_err() { + // replace the body of the mail with an encrypted, + // mime encoded version. + let outer_mail = MailContext::generate_outer_mail(encrypted); + if cx.actions.replace_body( + outer_mail + .body_to_string() + .as_bytes()).await.is_err() { log::error!("Cannot exchange body"); + return Status::Continue; + } + + // adjust headers + // This is mostly Content-Type related. + for line in outer_mail.get_header().iter_lines() { + // XXX: handle errors + if let Ok(key) = line.key_as_cstring() { + if let Ok(value) = line.value_as_cstring() { + let _ = cx.actions.change_header( + key, 1, + Some(value) + ).await; + } + } } }, Err(e) => { diff --git a/src/types/cheader.rs b/src/types/cheader.rs new file mode 100644 index 0000000..bda73d5 --- /dev/null +++ b/src/types/cheader.rs @@ -0,0 +1,128 @@ +//! CHeader struct +//! +//! This struct is for collecting header lines from the milter. + +use std::ffi::CString; +use std::fmt; + +pub struct CHeader { + lines: Vec<(CString, CString)> +} + +impl CHeader { + pub fn new() -> Self { + CHeader { + lines: Vec::new() + } + } + + pub fn add(mut self, key: CString, value: CString) -> Self { + self.lines.push((key, value)); + self + } + + pub fn append(&mut self, key: CString, value: CString) { + self.lines.push((key, value)); + } + + pub fn find(&self, key: &str) -> Option<(&str, &str)> { + let lower_key = key.to_lowercase(); + for (line_key, line_value) in self.lines.iter() { + if let Ok(line_compare) = line_key.to_str() { + if line_compare.to_ascii_lowercase() == lower_key { + if let Ok(line_value_as_str) = line_value.to_str() { + return Some( + (line_compare, line_value_as_str) + ); + } + } + } + } + None + } + + pub fn get_value_or<'ch>(&'ch self, key: &str, default: &'ch str) -> &'ch str { + if let Some((_, value)) = self.find(key) { + value + } else { + default + } + } + + pub fn iter_lines(&self) -> std::slice::Iter<'_, (CString, CString)> { + self.lines.iter() + } +} + +impl fmt::Display for CHeader { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", [ + self.iter_lines() + .filter_map(|(k, v)| { + match (k.to_str(), v.to_str()) { + (Ok(k), Ok(v)) => Some([k, v].join(": ")), + _ => None, + } + }) + .collect::>() + .join("\r\n"), + "\r\n".to_string()] + .join("") + ) + } +} + +#[cfg(test)] +pub mod tests { + use std::ffi::CString; + use super::CHeader; + + +#[test] + pub fn basic_functionality() { + let cheader = CHeader::new() + .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) + .add(CString::new("Content-Transfer-Encoding").unwrap(), CString::new("7bit").unwrap()); + + assert_eq!("Content-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n", cheader.to_string()); + } + +#[test] + pub fn find() { + let cheader = CHeader::new() + .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) + .add(CString::new("Content-Transfer-Encoding").unwrap(), CString::new("7bit").unwrap()); + + let line = cheader.find("content-type"); + + assert!(line.is_some()); + + let (key, value) = line.unwrap(); + + assert_eq!("Content-Type", key); + assert_eq!("text/plain", value); + } + +#[test] + pub fn find_none() { + let cheader = CHeader::new() + .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) + .add(CString::new("Content-Transfer-Encoding").unwrap(), CString::new("7bit").unwrap()); + + let line = cheader.find("foo-bar"); + + assert!(line.is_none()); + } + +#[test] + pub fn find_with_default() { + let cheader = CHeader::new() + .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) + .add(CString::new("Content-Transfer-Encoding").unwrap(), CString::new("7bit").unwrap()); + + let value = cheader.get_value_or("foo-bar", "baaz"); + + assert_eq!(value, "baaz"); + } +} + diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 310a2bc..3ca6dce 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -9,8 +9,9 @@ //! email. use anyhow; -use sequoia_openpgp::policy::StandardPolicy; use std::path::PathBuf; +use std::ffi::CString; +use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::Cert; use sequoia_cert_store::CertStore; use sequoia_directories::Home; @@ -55,11 +56,15 @@ impl<'hc> HuskContext<'hc> { } /// If the `HuskContext` is reused for another email, `reset_mail` - /// creates the neccessary context for it. + /// creates the neccessary (new) context for it. pub fn reset_mail(&mut self) { self.mail = MailContext::new(); } + pub fn add_to_header(&mut self, key: CString, value: CString) { + self.mail.add_to_header(key, value); + } + /// Store the sender of an email in the current `MailContext`. pub fn set_sender(&mut self, sender: String) { self.mail.set_sender(sender); diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 9f16827..117e76a 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -4,10 +4,16 @@ //! to keep the bits and pieces together. use std::sync::Arc; +use std::ffi::CString; use bytes::{Bytes, BytesMut}; use sequoia_cert_store::LazyCert; use crate::types::recipient::Recipient; +use crate::types::cheader::CHeader; +use crate::types::mime::header_line::HeaderLine; +use crate::types::mime::header::Header; +use crate::types::mime::part::Part; +use crate::types::mime::multi_part::MultiPart; #[derive(PartialEq)] pub enum ProtectionPossibility { @@ -23,7 +29,7 @@ pub enum ProtectionPossibility { pub struct MailContext<'mc> { pub sender: Option, pub recipients: Vec>, - // header + pub header: CHeader, body: Vec, pub body_size: usize, } @@ -34,6 +40,17 @@ impl<'mc> MailContext<'mc> { MailContext { sender: None, recipients: Vec::new(), + header: CHeader::new(), + body: Vec::new(), + body_size: 0, + } + } + + pub fn from_header(header: CHeader) -> Self { + MailContext { + sender: None, + recipients: Vec::new(), + header, body: Vec::new(), body_size: 0, } @@ -43,6 +60,10 @@ impl<'mc> MailContext<'mc> { self.sender = Some(sender); } + pub fn add_to_header(&mut self, key: CString, value: CString) { + self.header.append(key, value); + } + pub fn add_recipient(&mut self, rcpt: String, certs: Vec>>) { let mut recipient: Recipient = rcpt.into(); recipient.certs = certs; @@ -63,6 +84,71 @@ impl<'mc> MailContext<'mc> { buffer.into() } + pub fn generate_inner_mail(&self, body: Bytes) -> MultiPart<'mc> { + let header = &self.header; + let inner = Part::with_body(body) + .set_header(Header::new() + .add( + HeaderLine::new("Content-Type") + .with(header.get_value_or("content-type", "text/plain") + .to_string())) + .add( + HeaderLine::new("Content-Transfer-Encoding") + .with(header.get_value_or("content-transfer-encoding", "7bit") + .to_string())) + ); + + let mut inner_mail = MultiPart::new() + .add_part(inner); + + inner_mail.add_header_line( + HeaderLine::new("Content-Type") + .with("multipart/mixed".to_string()) + .with(format!("boundary=\"{}\"", inner_mail.get_boundary())) + .with("protected-headers=\"v1\"".to_string()) + ); + for key in ["From", "To", "Subject", "Message-Id"] { + if let Some((_, value)) = header.find(key) { + inner_mail.add_header_line( + HeaderLine::new(key).with(value.to_string())); + } + } + inner_mail + } + + pub fn generate_outer_mail<'b>(body: Bytes) -> MultiPart<'b> { + let version = Part::with_body("Version: 1".into()) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type") + .with("application/pgp-encrypted".to_string())) + .add(HeaderLine::new("Content-Description") + .with("PGP/MIME version identification".to_string())) + ); + let cryptpart = Part::with_body(body) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type") + .with("application/octet-stream".to_string()) + .with("name=\"encrypted.asc\"".to_string())) + .add(HeaderLine::new("Content-Description") + .with("OpenPGP encrypted message".to_string())) + .add(HeaderLine::new("Content-Disposition") + .with("inline".to_string()) + .with("filename=\"encrypted.asc\"".to_string())) + ); + + let mut outer = MultiPart::new() + .add_part(version) + .add_part(cryptpart); + outer.add_header_line( + HeaderLine::new("Content-Type") + .with("multipart/encrypted".to_string()) + .with("protocol=\"application/pgp-encrypted\"".to_string()) + .with(format!("boundary=\"{}\"", outer.get_boundary()) + ) + ); + outer + } + } /// Default Mailcontext @@ -94,3 +180,45 @@ impl From<&MailContext<'_>> for ProtectionPossibility { } } } + +#[cfg(test)] +mod tests { + + use std::ffi::CString; + use crate::types::cheader::CHeader; + use crate::types::mail_context::MailContext; + + #[test] + fn test_inner_mail() { + + let orig_header = CHeader::new() + .add(CString::new("From").unwrap(), CString::new("alice@example.com").unwrap()) + .add(CString::new("To").unwrap(), CString::new("bob@example.com").unwrap()) + .add(CString::new("Subject").unwrap(), CString::new("The original Subject").unwrap()) + .add(CString::new("Content-Type").unwrap(), CString::new("test/plain").unwrap()) + .add(CString::new("Content-Transfer-Encoding").unwrap(), CString::new("7bit").unwrap()) + // should not be copied into the inner mail + .add(CString::new("other-header").unwrap(), CString::new("should_not_appear").unwrap()); + + let orig_body = "This is the body".as_bytes().to_vec(); + + let context = MailContext::from_header(orig_header); + let inner_mail = context.generate_inner_mail(orig_body.into()); + + let intended_result = [ + format!("Content-Type: multipart/mixed; boundary=\"{}\"; protected-headers=\"v1\"", inner_mail.get_boundary()), + "From: alice@example.com".to_string(), + "To: bob@example.com".to_string(), + "Subject: The original Subject".to_string(), + "".to_string(), + format!("--{}", inner_mail.get_boundary()), + "Content-Type: test/plain".to_string(), + "Content-Transfer-Encoding: 7bit".to_string(), + "".to_string(), + "This is the body".to_string(), + format!("--{}--", inner_mail.get_boundary()) ].join("\r\n"); + + assert_eq!(intended_result, inner_mail.to_string()); + } +} + diff --git a/src/types/mime/header.rs b/src/types/mime/header.rs new file mode 100644 index 0000000..aba1964 --- /dev/null +++ b/src/types/mime/header.rs @@ -0,0 +1,59 @@ +//! a complete mime header + +use std::fmt; +use super::header_line::HeaderLine; + +pub struct Header<'h> { + lines: Vec> +} + +impl<'h> Header<'h> { + + pub fn new() -> Self { + Header { + lines: Vec::new() + } + } + + /// Add a `HeaderLine`, this function returns the `Header` back to the + /// caller. + pub fn add(mut self, line: HeaderLine<'h>) -> Self { + self.lines.push(line); + self + } + + /// Add a `HeaderLine`. + pub fn append(&mut self, line: HeaderLine<'h>) { + self.lines.push(line); + } + + pub fn iter_lines(&self) -> std::slice::Iter<'_, HeaderLine<'_>> { + self.lines.iter() + } +} + +impl fmt::Display for Header<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", [ + self.lines.iter() + .map(|line: &HeaderLine| line.to_string()) + .collect::>() + .join("\r\n"), + "\r\n".into()].join("") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_functionality() { + let header = Header::new() + .add(HeaderLine::new("Content-Type").with("text/plain".to_string())) + .add(HeaderLine::new("Content-Transfer-Encoding").with("7bit".to_string())); + assert_eq!(header.to_string(), "Content-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n"); + } +} + diff --git a/src/types/mime/header_line.rs b/src/types/mime/header_line.rs new file mode 100644 index 0000000..a79db7b --- /dev/null +++ b/src/types/mime/header_line.rs @@ -0,0 +1,75 @@ +//! header line +//! +//! A Line of a mime header. It consists of a `key` and a `value` which +//! itself is a list of attributes. +//! +//! Example: +//! "Content-Type: text/plain; charset=UTF-8" +//! key: "Content-Type" +//! attribute 1: "text/plain" +//! attribute 2: "charset=UTF-8" + +use std::fmt; +use std::ffi::CString; +use anyhow::Result; + +pub struct HeaderLine<'h> { + key: &'h str, + value: Vec, +} + +impl<'h> HeaderLine<'h> { + pub fn new(key: &'h str) -> Self { + HeaderLine { + key, + value: Vec::new() + } + } + + pub fn with(mut self, part: String) -> Self { + self.value.push(part); + self + } + + /// Combine the parts of `value` to a `String`. Add '; ' as a + /// separator. + pub fn value_to_string(&self) -> String { + self.value.join("; ") + } + + pub fn value_size(&self) -> usize { + self.value_to_string().len() + } + + pub fn key_as_cstring(&self) -> Result { + CString::new(self.key) + .map_err(|e| e.into()) + } + + pub fn value_as_cstring(&self) -> Result { + CString::new(self.value_to_string()) + .map_err(|e| e.into()) + } +} + +impl fmt::Display for HeaderLine<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", + [self.key, self.value_to_string().as_str()].join(": ") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_functionality() { + let header_line = HeaderLine::new("Content-Type") + .with("text/plain".to_string()) + .with("charset=UTF-8".to_string()); + assert_eq!(&header_line.to_string(), "Content-Type: text/plain; charset=UTF-8"); + assert_eq!(&header_line.value_to_string(), "text/plain; charset=UTF-8"); + } +} diff --git a/src/types/mime/mod.rs b/src/types/mime/mod.rs new file mode 100644 index 0000000..dba107f --- /dev/null +++ b/src/types/mime/mod.rs @@ -0,0 +1,9 @@ +//! Multipart MIME +//! +//! This module contains code to generate a multipart mime message. +//! (no parsing) + +pub mod header_line; +pub mod header; +pub mod part; +pub mod multi_part; diff --git a/src/types/mime/multi_part.rs b/src/types/mime/multi_part.rs new file mode 100644 index 0000000..ba96802 --- /dev/null +++ b/src/types/mime/multi_part.rs @@ -0,0 +1,124 @@ +//! multipart mime + +use std::fmt; +use std::iter::repeat_with; + +use super::{header::Header, header_line::HeaderLine, part::Part}; + +pub struct MultiPart<'m> { + header: Header<'m>, + parts: Vec>, + boundary: String +} + +impl<'m> MultiPart<'m> { + + pub fn new() -> Self { + MultiPart { + header: Header::new(), + parts: Vec::new(), + boundary: MultiPart::new_boundary() + } + } + + pub fn with_boundary(boundary: String) -> Self { + MultiPart { + header: Header::new(), + parts: Vec::new(), + boundary: boundary + } + } + + pub fn add_part(mut self, part: Part<'m>) -> Self { + self.parts.push(part); + self + } + + pub fn set_header(mut self, header: Header<'m>) -> Self { + self.header = header; + self + } + + pub fn get_header(&self) -> &Header<'_> { + &self.header + } + + pub fn add_header_line(&mut self, line: HeaderLine<'m>) { + self.header.append(line); + } + + pub fn get_boundary(&self) -> String { + self.boundary.clone() + } + + pub fn new_boundary() -> String { + let random_part: String = repeat_with(fastrand::alphanumeric).take(50).collect(); + + ["------", random_part.as_str()].join("") + } + + pub fn body_to_string(&self) -> String { + + format!("--{}\r\n{}\r\n--{}--", + self.boundary, + self.parts.iter() + .map(|part| part.to_string()) + .collect::>() + .join(format!("\r\n--{}\r\n", self.boundary).as_str()), + self.boundary) + + } + + pub fn header_to_string(&self) -> String { + self.header.to_string() + } + +} + +impl fmt::Display for MultiPart<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}\r\n--{}\r\n{}\r\n--{}--", + self.header, + self.boundary, + self.parts.iter() + .map(|part| part.to_string()) + .collect::>() + .join(format!("\r\n--{}\r\n", self.boundary).as_str()), + self.boundary) + } +} + + +#[cfg(test)] +mod tests { + use crate::types::mime::{header::Header, header_line::HeaderLine, part::Part}; + + use super::MultiPart; + + + #[test] + fn basic_functionality() { + let multi = MultiPart::with_boundary("test-boundary".to_string()) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type").with("multipart/encrypted".to_string())) + ) + .add_part(Part::with_body("body 1".as_bytes().into()) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type") + .with("text/plain".to_string()) + ) + ) + ) + .add_part(Part::with_body("body 2".as_bytes().into()) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type") + .with("text/plain".to_string()) + ) + ) + ); + assert_eq!(multi.to_string(),"Content-Type: multipart/encrypted\r\n\r\n--test-boundary\r\nContent-Type: text/plain\r\n\r\nbody 1\r\n--test-boundary\r\nContent-Type: text/plain\r\n\r\nbody 2\r\n--test-boundary--"); + assert_eq!(multi.body_to_string(),"--test-boundary\r\nContent-Type: text/plain\r\n\r\nbody 1\r\n--test-boundary\r\nContent-Type: text/plain\r\n\r\nbody 2\r\n--test-boundary--"); + assert_eq!(multi.header_to_string(),"Content-Type: multipart/encrypted\r\n"); + } +} + diff --git a/src/types/mime/part.rs b/src/types/mime/part.rs new file mode 100644 index 0000000..5d355e8 --- /dev/null +++ b/src/types/mime/part.rs @@ -0,0 +1,63 @@ +//! Mime part + +use std::fmt; +use bytes::Bytes; + +use super::header::Header; + +pub struct Part<'p> { + header: Header<'p>, + body: Bytes +} + +impl<'p> Part<'p> { + + pub fn with_body(body: Bytes) -> Self { + Part { + header: Header::new(), + body: body + } + } + + pub fn from_body(body: Bytes) -> Self { + Part { + header: Header::new(), + body: body + } + } + + pub fn set_header(mut self, header: Header<'p>) -> Self { + self.header = header; + self + } +} + +impl fmt::Display for Part<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", [ + self.header.to_string(), + String::from_utf8_lossy(&self.body.to_vec()[..]).into() + ].join("\r\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::mime::header_line::HeaderLine; + + #[test] + fn basic_functionality() { + let body = Bytes::from("this is a body"); + let part = Part::with_body(body) + .set_header(Header::new() + .add(HeaderLine::new("Content-Type") + .with("text/plain".to_string()) + ) + ); + + assert_eq!(part.to_string(), "Content-Type: text/plain\r\n\r\nthis is a body"); + } +} + + diff --git a/src/types/mod.rs b/src/types/mod.rs index 7d91ab1..3bdff3a 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -4,4 +4,5 @@ pub mod mail_context; pub mod errors; pub mod recipient; pub mod introducer; - +pub mod mime; +pub mod cheader;