From d54cc480b45aa03a05fa47e2b5f2bc578991748b Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Tue, 14 Jul 2026 11:28:34 +0200 Subject: [PATCH] Add footer - Add a footer to each mail resulting from a split into an encrypted and an unencrypted version. The footer contains the recipients of the mail which are excluded in the corresponding stream. - Move the construction of the `HuskContext` to the `mail` milter-command, as `helo` is used by scrapers to detect mailservers. - Use `CertD::trust_root()` to get the trust root - it creates one if no root can be found. - Add `chrono` dependency. --- Cargo.lock | 6 ++--- Cargo.toml | 4 ++++ src/common/crypto.rs | 37 +++++++---------------------- src/daemon/mod.rs | 52 +++++++++++++++++++++++------------------ src/mail.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ src/types/defaults.rs | 3 +++ 6 files changed, 102 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 035d83b..4f93ee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1261,6 +1261,7 @@ dependencies = [ "aho-corasick", "anyhow", "bytes", + "chrono", "clap", "clap_complete", "daemonix", @@ -2610,8 +2611,7 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sequoia-cert-store" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e07e420427eb5e0dc3bb61172d181fa3c87afb2a77e8c629e920f54a291a6d" +source = "git+https://gitlab.com/sequoia-pgp/sequoia-cert-store?branch=malte%2Fassert_base_dir#ef9928239012a78b16be82b70420973a06e309a4" dependencies = [ "anyhow", "crossbeam", @@ -2624,7 +2624,7 @@ dependencies = [ "sequoia-net", "sequoia-openpgp", "smallvec", - "thiserror 2.0.18", + "thiserror 1.0.69", "tokio", "url", ] diff --git a/Cargo.toml b/Cargo.toml index d7192ca..91598cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ rust-version = "1.85" aho-corasick = "1.1.4" anyhow = "1.0.100" bytes = "1.11.1" +chrono = "0.4.45" clap = { version = "4", features = ["derive"] } fastrand = "2.4.1" indymilter = "0.3.0" @@ -46,3 +47,6 @@ clap_complete = "4" [profile.release] lto = true codegen-units = 1 + +[patch.crates-io] +sequoia-cert-store = { git = "https://gitlab.com/sequoia-pgp/sequoia-cert-store", branch = "malte/assert_base_dir" } diff --git a/src/common/crypto.rs b/src/common/crypto.rs index 640b7a3..8130b1a 100644 --- a/src/common/crypto.rs +++ b/src/common/crypto.rs @@ -23,7 +23,6 @@ use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter}; use sequoia_openpgp::{Fingerprint, Cert, Packet}; -use sequoia_openpgp::cert::raw::RawCertParser; use sequoia_openpgp::types::SignatureType; use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate}; use sequoia_cert_store::store::MergePublicCollectStats; @@ -45,35 +44,15 @@ const HUSK_SIG_NOTATION: &str = "signing_key@husk-project.org"; /// Returns the local trust root from the cert_store. If the trust root /// cannot be found, it returns an error. pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { - - let root = cert_store.certd() - .and_then(|certd| { - match certd.certd().get(sequoia_cert_store::store::openpgp_cert_d::TRUST_ROOT) { - Ok(Some((_tag, bytes))) => Some(bytes), - Ok(None) => None, - Err(_) => None - } - }) - .and_then(|bytes| { - match RawCertParser::from_bytes(&bytes[..]) { - Ok(mut parser) => { - match parser.next() { - Some(Ok(cert)) => { - Cert::from_bytes(cert.as_bytes()).ok() - }, - Some(Err(_)) - | None => None - } - }, - Err(_) => None, - } - }); - - match root { - Some(r) => Ok(r), - None => Err(HuskError::NoLocalTrustRoot.into()) + if let Some((root, created)) = cert_store.certd().and_then(|certd| { + certd.trust_root().ok() }) { + if created { + log::info!("Local trust root not found - created one"); + } + Ok(root.to_cert()?.clone()) + } else { + Err(HuskError::NoLocalTrustRoot.into()) } - } /// Returns all local certificates with an authenticated binding to user id 'email'. diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index f8cfcee..d61c927 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,7 +1,7 @@ //! the actual daemon use anyhow; -use bytes::Bytes; +use bytes::{Bytes, BytesMut}; use std::ffi::CString; use tokio::signal; use tokio::net::TcpListener; @@ -39,8 +39,8 @@ impl Daemon { let callbacks = Callbacks::new() .on_negotiate(|cx, actions, opts| Box::pin(Self::handle_negotiate(cx, actions, opts))) .on_connect(|cx, hostname, socket_info| Box::pin(Self::handle_connect(cx, hostname, socket_info))) - .on_helo(move |cx, hostname| Box::pin(Self::handle_helo(cx, config_container.clone(), hostname))) - .on_mail(|cx, args| Box::pin(Self::handle_mail(cx, args))) + .on_helo(|cx, hostname| Box::pin(Self::handle_helo(cx, hostname))) + .on_mail(move |cx, args| Box::pin(Self::handle_mail(cx, config_container.clone(), args))) .on_rcpt(|cx, args| Box::pin(Self::handle_rcpt(cx, args))) .on_data(|cx| Box::pin(Self::handle_data(cx))) .on_header(|cx, name, value| Box::pin(Self::handle_header(cx, name, value))) @@ -80,26 +80,15 @@ impl Daemon { Status::Continue } - async fn handle_helo(cx: &mut Context>, config_container: HuskConfigContainer, hostname: CString) -> Status { + async fn handle_helo(_cx: &mut Context>, hostname: CString) -> Status { log::debug!("HELO: hostname: {hostname:?}"); - let config: HuskConfig = config_container.into(); + // XXX: check for reinjection - // setup Context - match HuskContext::new(&config) { - Ok(context) => { - log::debug!("found introducers: {:?}", context.introducers); - cx.data = Some(context); - Status::Continue - }, - Err(e) => { - log::error!("{}", e); - Status::Tempfail - } - } + Status::Continue } - async fn handle_mail(cx: &mut Context>, args: Vec) -> Status { + async fn handle_mail(cx: &mut Context>, config_container: HuskConfigContainer, args: Vec) -> Status { log::debug!("MAIL: {args:?}"); let mut sender: Option = None; @@ -117,7 +106,20 @@ impl Daemon { context.set_sender(sender.unwrap()); Status::Continue } else { - Status::Tempfail + let config: HuskConfig = config_container.into(); + + // setup Context + match HuskContext::new(&config) { + Ok(mut context) => { + context.set_sender(sender.unwrap()); + cx.data = Some(context); + Status::Continue + }, + Err(e) => { + log::error!("{}", e); + Status::Tempfail + } + } } } @@ -213,7 +215,9 @@ impl Daemon { match ProtectionPossibility::from(&context.mail) { ProtectionPossibility::Full => { log::debug!("completely encryptable"); - let inner_mail = MailContext::generate_inner_mail(&context.mail.header, body); + let mut body: BytesMut = body.into(); + body.extend_from_slice(mail::generate_footer(&vec![]).as_bytes()); + let inner_mail = MailContext::generate_inner_mail(&context.mail.header, body.into()); // if an error occures the body is not exchanged match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) { @@ -257,19 +261,23 @@ impl Daemon { log::debug!("partially encryptable"); // Generate a new mail for the unencryptable recipients. + let mut unencryted_body: BytesMut = body.clone().into(); + unencryted_body.extend_from_slice(mail::generate_footer(&encryptable).as_bytes()); let header = context.mail.header.without_recipients(&encryptable); if mail::smtp_inject( from, &unencryptable, header, - &body).await.is_err() { + &unencryted_body.into()).await.is_err() { // smtp_inject() already logged the error. return Status::Tempfail; } // Encrypt for remaining recipients. + let mut encrypted_body: BytesMut = body.into(); + encrypted_body.extend_from_slice(mail::generate_footer(&unencryptable).as_bytes()); let header = context.mail.header.without_recipients(&unencryptable); - let inner_mail = MailContext::generate_inner_mail(&header, body); + let inner_mail = MailContext::generate_inner_mail(&header, encrypted_body.into()); // If an error occures, the body is not exchanged. match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &encryptable) { diff --git a/src/mail.rs b/src/mail.rs index ea839c9..c51bdc2 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -11,8 +11,10 @@ use lettre::transport::smtp::{ use lettre::Address; use tokio::task::JoinSet; use std::time::Duration; +use chrono::Utc; use crate::types::cheader::CHeader; +use crate::types::defaults; use crate::types::errors::HuskError; use crate::types::recipient::Recipient; @@ -128,3 +130,55 @@ pub async fn smtp_inject(from: String, to: &Vec>, header: CHeader, } } +pub fn generate_footer(rcpts: &Vec) -> String { + internal_generate_footer(Utc::now().to_rfc2822(), rcpts) +} + +pub(crate) fn internal_generate_footer(now: String, other_rpcts: &Vec) -> String { + let mut footer = defaults::FOOTER.replace("{date}", now.as_str()); + + if ! other_rpcts.is_empty() { + let rcpts = other_rpcts.iter() + .map(|r| r.email.clone()) + .collect::>() + .join(", "); + footer.push_str( + defaults::FOOTER_RECIPIENTS.replace( + "{rcpts}", + &rcpts).as_str() + ); + } + footer +} + + +#[cfg(test)] +pub mod tests { + + use crate::mail::internal_generate_footer; + use crate::types::recipient::Recipient; + + #[test] + fn test_footer() { + + let rcpts = vec![ + Recipient { + email: "alice@example.com".to_string(), + certs: Vec::new() + }, + Recipient { + email: "bob@example.com".to_string(), + certs: Vec::new() + } + ]; + + let footer = internal_generate_footer( + "1.1.1970".to_string(), + &rcpts + ); + + assert!(footer.contains("1.1.1970")); + assert!(footer.contains("alice@example.com")); + assert!(footer.contains("bob@example.com")); + } +} diff --git a/src/types/defaults.rs b/src/types/defaults.rs index 8f5d0d2..e6b9fec 100644 --- a/src/types/defaults.rs +++ b/src/types/defaults.rs @@ -8,6 +8,9 @@ pub const SUBJECT_REPLACEMENT: &str = "..."; pub const PID_FILE_LOCATION: &str = "/run/husk/pid"; +pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n"; +pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n"; + /// Default logging configuration. pub const CONSOLE_LOGGING: &str = " [appenders.console]