diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 5d7ec09..f8cfcee 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -202,58 +202,143 @@ impl Daemon { return Status::Accept; } - // possible protection - // none -> accept - // partial -> accept (later: split) - // full -> encrypt + let from = match context.mail.sender.clone() { + Some(f) => f, + None => { + log::error!("No sender given"); + return Status::Tempfail; + } + }; - let protection = ProtectionPossibility::from(&context.mail); + match ProtectionPossibility::from(&context.mail) { + ProtectionPossibility::Full => { + log::debug!("completely encryptable"); + let inner_mail = MailContext::generate_inner_mail(&context.mail.header, body); - if protection == ProtectionPossibility::Full { + // if an error occures the body is not exchanged + match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) { + Ok(encrypted) => { + // 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; + } - let inner_mail = context.mail.generate_inner_mail(body); - - // if an error occures the body is not exchanged - match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) { - Ok(encrypted) => { - // 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; + // 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; + } } } + // Change the subject + let _ = cx.actions.change_header( + "Subject", + 1, + Some(context.subject_replacement.clone())).await; + }, + Err(e) => { + log::error!("{}", e); } - // Change the subject - let _ = cx.actions.change_header( - "Subject", - 1, - Some(context.subject_replacement.clone())).await; - }, - Err(e) => { - log::error!("{}", e); } + }, + ProtectionPossibility::Partial(encryptable, unencryptable) => { + log::debug!("partially encryptable"); + // Generate a new mail for the unencryptable recipients. + + let header = context.mail.header.without_recipients(&encryptable); + if mail::smtp_inject( + from, + &unencryptable, + header, + &body).await.is_err() { + // smtp_inject() already logged the error. + return Status::Tempfail; + } + + // Encrypt for remaining recipients. + let header = context.mail.header.without_recipients(&unencryptable); + let inner_mail = MailContext::generate_inner_mail(&header, body); + + // If an error occures, the body is not exchanged. + match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &encryptable) { + Ok(encrypted) => { + // 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 + // XXX error handling + + // Remove unencryptable recipients from 'to', 'cc' + // and 'bcc'. + for (key, value) in header.iter_lines() { + match key.as_bytes().to_ascii_lowercase().as_slice() { + b"to" + | b"cc" + | b"bcc" => { + let _ = cx.actions.change_header( + key.clone(), + 1, + Some(value.clone()) + ).await; + }, + _ => { } + } + } + // Also remove recipients via the milter protocol. + for rcpt in unencryptable { + let _ = cx.actions.delete_recipient(rcpt.email).await; + } + + // This is 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; + } + } + } + // Change the subject. + let _ = cx.actions.change_header( + "Subject", + 1, + Some(context.subject_replacement.clone())).await; + }, + Err(e) => { + log::error!("{}", e); + } + } + }, + ProtectionPossibility::NoProtection => { + log::debug!("not encryptable"); + // do nothing } } } - log::logger().flush(); Status::Continue } diff --git a/src/mail.rs b/src/mail.rs index b7b209a..ea839c9 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,8 +1,22 @@ //! mail functions +use anyhow::Result; +use bytes::Bytes; +use bytes::BytesMut; use std::ffi::CString; use sequoia_openpgp::packet::UserID; +use lettre::transport::smtp::{ + client::SmtpConnection, commands::*, extension::ClientId, SMTP_PORT, +}; +use lettre::Address; +use tokio::task::JoinSet; +use std::time::Duration; +use crate::types::cheader::CHeader; +use crate::types::errors::HuskError; +use crate::types::recipient::Recipient; + +/// Parse the user id fields from a string. pub fn to_userid(rcpt: &CString) -> Option { match rcpt.to_str() { Ok(s) => Some(UserID::from(s)), @@ -10,6 +24,7 @@ pub fn to_userid(rcpt: &CString) -> Option { } } +/// Get the email from the string representation of a user id. pub fn to_email(rcpt: &CString) -> Option { if let Some(u) = to_userid(rcpt) { if let Ok(Some(e)) = u.email() { Some(e.to_string()) } @@ -32,3 +47,84 @@ pub fn is_email(email: &str) -> bool { pub fn is_domain(domain: &str) -> bool { is_email(format!("test@{}", domain).as_str()) } + +pub async fn smtp_inject(from: String, to: &Vec>, header: CHeader, body: &Bytes) -> Result<()> { + + let mut joinset: JoinSet> = JoinSet::new(); + + log::debug!("smtp inject: from {}", from); + + // build message + let mut header_size = 0; + for (key, value) in header.iter_lines() { + header_size += key.count_bytes(); + header_size += value.count_bytes(); + header_size += 4; // 4 is ': ' plus '\r\n' + } + + // The size of the whole message the sum of header and body size + '\r\n' + // as the dividing empty line. + let mut message = BytesMut::with_capacity(header_size + body.len() + 2); + for (key, value) in header.iter_lines() { + message.extend_from_slice(key.as_bytes()); + message.extend_from_slice(": ".as_bytes()); + message.extend_from_slice(value.as_bytes()); + message.extend_from_slice("\r\n".as_bytes()); + } + message.extend_from_slice(b"\r\n"); + message.extend_from_slice(&body[..]); + + // convert addresses + let from_email = CString::new(from.clone())?; + let from_email = to_email(&from_email).unwrap(); // XXX + let from_email: Address = from_email.try_into()?; + + let mut rcpts: Vec
= Vec::new(); + for rcpt in to { + let rcpt = rcpt.email.clone(); + rcpts.push(rcpt.try_into()?); + } + + let my_id = ClientId::Domain("husk.reinject".to_owned()); // TODO: add secret token? + joinset.spawn(async move { + + // XXX + // We connect to localhost - every other host would require implementing + // TLS. The milter protocol is unencrypted anyways. + match SmtpConnection::connect(("localhost", SMTP_PORT), Some(Duration::from_secs(5)), &my_id, None, None) { + Ok(mut client) => { + log::debug!("connected - start sending"); + + client.command(Mail::new(Some(from_email),vec![]))?; + for rcpt in rcpts { + client.command(Rcpt::new(rcpt, vec![]))?; + } + client.command(Data)?; + client.message(&message)?; + client.command(Quit)?; + + Ok(()) + }, + Err(e) => { + log::error!("cannot connect to localhost:{} - {:?}", SMTP_PORT, e); + Err(e.into()) + } + } + }); + + match joinset.join_all().await.first() { + Some(Ok(_)) => { + log::debug!("mail injection done."); + Ok(()) + }, + Some(Err(e)) => { + log::error!("mail injection failed: {:?}", e); + Err(HuskError::SmtpInjectionFailure(e.to_string()).into()) + }, + _ => { + log::error!("mail injection failed without reason"); + Err(HuskError::SmtpInjectionFailure("no reason".to_string()).into()) + }, + } +} + diff --git a/src/types/cheader.rs b/src/types/cheader.rs index bda73d5..9ad1319 100644 --- a/src/types/cheader.rs +++ b/src/types/cheader.rs @@ -5,6 +5,11 @@ use std::ffi::CString; use std::fmt; +use anyhow::Result; + +use crate::types::recipient::Recipient; + +#[derive(Debug, PartialEq, Clone)] pub struct CHeader { lines: Vec<(CString, CString)> } @@ -52,6 +57,47 @@ impl CHeader { pub fn iter_lines(&self) -> std::slice::Iter<'_, (CString, CString)> { self.lines.iter() } + + /// Remove a recipient from a value taken from a to, cc or bcc header + /// line. Only the email address is matched. + pub fn remove_recipient(header_value: &CString, to_remove: &Vec) -> Result { + let content = String::from_utf8_lossy(header_value.as_bytes()); + let parts = content.split(",") + .filter(|part| { + let mut keep = true; + let p = part.to_ascii_lowercase(); + for rcpt in to_remove { + let rcpt = rcpt.email.to_ascii_lowercase(); + if p.contains(&rcpt) { keep = false; break; } + } + keep + }) + .collect::>(); + CString::new(parts.join(",")) + .map_err(|e| e.into()) + } + + /// Returns a new header with `to_remove` removed from to, cc and bcc. + pub fn without_recipients(&self, to_remove: &Vec) -> CHeader { + let mut result = CHeader::new(); + + for (key, value) in self.iter_lines() { + let new_value = match key.as_bytes().to_ascii_lowercase().as_slice() { + b"to" + | b"cc" + | b"bcc" => Self::remove_recipient(value, to_remove), + _ => Ok(value.clone()) + }; + if let Ok(value) = new_value { + if ! value.is_empty() { + result.append(key.clone(), value); + } + } + } + + result + } + } impl fmt::Display for CHeader { @@ -75,10 +121,12 @@ impl fmt::Display for CHeader { #[cfg(test)] pub mod tests { use std::ffi::CString; + use crate::types::recipient::Recipient; + use super::CHeader; -#[test] + #[test] pub fn basic_functionality() { let cheader = CHeader::new() .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) @@ -87,7 +135,7 @@ pub mod tests { assert_eq!("Content-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n", cheader.to_string()); } -#[test] + #[test] pub fn find() { let cheader = CHeader::new() .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) @@ -103,7 +151,7 @@ pub mod tests { assert_eq!("text/plain", value); } -#[test] + #[test] pub fn find_none() { let cheader = CHeader::new() .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) @@ -114,7 +162,7 @@ pub mod tests { assert!(line.is_none()); } -#[test] + #[test] pub fn find_with_default() { let cheader = CHeader::new() .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) @@ -124,5 +172,27 @@ pub mod tests { assert_eq!(value, "baaz"); } + + #[test] + pub fn remove_recipients() { + let header = CHeader::new() + .add(CString::new("To").unwrap(), CString::new("Alice , Bob ").unwrap()) + .add(CString::new("Cc").unwrap(), CString::new("carol@example.com").unwrap()); + + let modified_header = header.without_recipients( + &vec![ Recipient::from("bob@other.org".to_string()) ] + ); + let expected_header = CHeader::new() + .add(CString::new("To").unwrap(), CString::new("Alice ").unwrap()) + .add(CString::new("Cc").unwrap(), CString::new("carol@example.com").unwrap()); + assert_eq!(modified_header, expected_header); + + let modified_header = header.without_recipients( + &vec![ Recipient::from("CaRol@exAmple.Com".to_string()) ] + ); + let expected_header = CHeader::new() + .add(CString::new("To").unwrap(), CString::new("Alice , Bob ").unwrap()); + assert_eq!(modified_header, expected_header); + } } diff --git a/src/types/errors.rs b/src/types/errors.rs index 7ccf040..11d269f 100644 --- a/src/types/errors.rs +++ b/src/types/errors.rs @@ -24,4 +24,6 @@ pub enum HuskError { InvalidDomainName(String), #[error("Certificate has no user ids - {0}")] NoUserIDs(String), + #[error("Failing to send mail: {0}")] + SmtpInjectionFailure(String), } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 117e76a..423acc7 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -15,17 +15,15 @@ use crate::types::mime::header::Header; use crate::types::mime::part::Part; use crate::types::mime::multi_part::MultiPart; -#[derive(PartialEq)] -pub enum ProtectionPossibility { +pub enum ProtectionPossibility<'mc> { // no recipient has a cert NoProtection, // some recipients have a cert (but not all) - Partial, + Partial(Vec>, Vec>), // all recipients have a cert Full } - pub struct MailContext<'mc> { pub sender: Option, pub recipients: Vec>, @@ -84,8 +82,8 @@ impl<'mc> MailContext<'mc> { buffer.into() } - pub fn generate_inner_mail(&self, body: Bytes) -> MultiPart<'mc> { - let header = &self.header; + pub fn generate_inner_mail(header: &CHeader, body: Bytes) -> MultiPart<'mc> { + //let header = &self.header; let inner = Part::with_body(body) .set_header(Header::new() .add( @@ -158,22 +156,27 @@ impl<'mc> Default for MailContext<'mc> { } } -impl From<&MailContext<'_>> for ProtectionPossibility { - fn from(mail_context: &MailContext<'_>) -> ProtectionPossibility { +impl<'mc> From<&MailContext<'mc>> for ProtectionPossibility<'mc> { + fn from(mail_context: &MailContext<'mc>) -> ProtectionPossibility<'mc> { let mut can_encrypt = false; let mut cannot_encrypt = false; + let mut encrypted_rcpt = Vec::new(); + let mut unencrypted_rcpt = Vec::new(); + for rcpt in mail_context.recipients.iter() { if rcpt.can_encrypt() { can_encrypt = true; + encrypted_rcpt.push(rcpt.clone()); } else { cannot_encrypt = true; + unencrypted_rcpt.push(rcpt.clone()); } } match (can_encrypt, cannot_encrypt) { (true, false) => ProtectionPossibility::Full, - (true, true) => ProtectionPossibility::Partial, + (true, true) => ProtectionPossibility::Partial(encrypted_rcpt, unencrypted_rcpt), (false, true) => ProtectionPossibility::NoProtection, // (false, false) occurs if there are no recipients (false, false) => ProtectionPossibility::NoProtection, @@ -185,8 +188,17 @@ impl From<&MailContext<'_>> for ProtectionPossibility { mod tests { use std::ffi::CString; + use std::sync::Arc; + + use anyhow::Result; + use sequoia_cert_store::LazyCert; + use sequoia_openpgp::cert::CertBuilder; + use crate::types::cheader::CHeader; use crate::types::mail_context::MailContext; + use crate::types::recipient::Recipient; + + use super::ProtectionPossibility; #[test] fn test_inner_mail() { @@ -202,8 +214,7 @@ mod tests { 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 inner_mail = MailContext::generate_inner_mail(&orig_header, orig_body.into()); let intended_result = [ format!("Content-Type: multipart/mixed; boundary=\"{}\"; protected-headers=\"v1\"", inner_mail.get_boundary()), @@ -220,5 +231,80 @@ mod tests { assert_eq!(intended_result, inner_mail.to_string()); } + + #[test] + fn test_recipient_splitting() -> Result<()> { + let (alice_cert, _) = CertBuilder::general_purpose(["alice@example.com"]) + .generate()?; + + // Mixed recipients: alice can encrypt, bob doesn't. + let mut mail_context = MailContext::new(); + mail_context.recipients = vec![ + Recipient { + email: "alice@example.com".to_string(), + certs: vec![Arc::new(LazyCert::from_cert(alice_cert))], + }, + Recipient { + email: "bob@example.com".to_string(), + certs: Vec::new() + } + ]; + + let protection = ProtectionPossibility::from(&mail_context); + + assert!(matches!(protection, ProtectionPossibility::Partial(_, _))); + + if let ProtectionPossibility::Partial(a, b) = protection { + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + + let alice = a.iter().next().unwrap(); + assert_eq!(alice.email, "alice@example.com"); + + let bob = b.iter().next().unwrap(); + assert_eq!(bob.email, "bob@example.com"); + } else { + return Err(anyhow::anyhow!("this should be unreachable")); + } + + // Both recipients can encrypt. + let (alice_cert, _) = CertBuilder::general_purpose(["alice@example.com"]) + .generate()?; + let (bob_cert, _) = CertBuilder::general_purpose(["bob@example.com"]) + .generate()?; + let mut mail_context = MailContext::new(); + mail_context.recipients = vec![ + Recipient { + email: "alice@example.com".to_string(), + certs: vec![Arc::new(LazyCert::from_cert(alice_cert))], + }, + Recipient { + email: "bob@example.com".to_string(), + certs: vec![Arc::new(LazyCert::from_cert(bob_cert))], + } + ]; + + let protection = ProtectionPossibility::from(&mail_context); + + assert!(matches!(protection, ProtectionPossibility::Full)); + + // No recipient can encrypt. + let mut mail_context = MailContext::new(); + mail_context.recipients = vec![ + Recipient { + email: "alice@example.com".to_string(), + certs: Vec::new() + }, + Recipient { + email: "bob@example.com".to_string(), + certs: Vec::new() + } + ]; + + let protection = ProtectionPossibility::from(&mail_context); + + assert!(matches!(protection, ProtectionPossibility::NoProtection)); + Ok(()) + } }