From 56647806b7387deeaa5d0ece164734ac617c8e33 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Tue, 9 Dec 2025 12:11:07 +0100 Subject: [PATCH 01/12] Steps towards encryption - Added certstore and retrieval of the local trust root. - Major work on keeping the context between calls to the milter. - Added a context for an email in processing - Added Husk specific errors --- Cargo.toml | 4 +++ config/config.toml | 2 ++ src/config.rs | 14 ++++++++- src/crypto.rs | 47 ++++++++++++++++++++++++++++ src/daemon/mod.rs | 66 +++++++++++++++++++++++++-------------- src/main.rs | 14 ++++----- src/types/errors.rs | 15 +++++++++ src/types/husk_context.rs | 36 ++++++++++++++++----- src/types/mail_context.rs | 37 ++++++++++++++++++++++ src/types/mod.rs | 2 ++ 10 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 src/crypto.rs create mode 100644 src/types/errors.rs create mode 100644 src/types/mail_context.rs diff --git a/Cargo.toml b/Cargo.toml index 2ac0421..7bf02a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,11 @@ bytes = "1.10.1" indymilter = "0.3.0" log = "0.4.27" log4rs = "1.3.0" +sequoia-cert-store = "0.7.1" +sequoia-directories = "0.1.0" +sequoia-openpgp = "2.1.0" serde = "1.0.228" serde_derive = "1.0.228" +thiserror = "2.0.17" tokio = { version = "1.47.1", features = [ "tokio-macros", "rt-multi-thread", "signal" ] } toml = "0.9.8" diff --git a/config/config.toml b/config/config.toml index cea6249..9246d5d 100644 --- a/config/config.toml +++ b/config/config.toml @@ -3,4 +3,6 @@ # connection = "localhost:3000" +sequoia_home = "/tmp/sq_home" + logfile_config = "./config/log4rs.yml" diff --git a/src/config.rs b/src/config.rs index be24d9c..987db39 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,11 +3,13 @@ // use std::fs; +use std::sync::{Arc, Mutex}; use serde_derive::Deserialize; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone)] pub struct HuskConfig { pub connection: String, + pub sequoia_home: String, logfile_config: Option, } @@ -41,3 +43,13 @@ impl HuskConfig { } } } + +pub type HuskConfigContainer = Arc>; + +impl From for HuskConfig { + fn from(container: HuskConfigContainer) -> Self { + let config = container.lock().unwrap(); + config.clone() + } +} + diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..ef55fb6 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,47 @@ +// +// Husk milter +// +// cryptographic functions +// + +use anyhow; +use sequoia_openpgp::parse::Parse; +use sequoia_openpgp::{Fingerprint, Cert}; +use sequoia_openpgp::cert::raw::RawCertParser; +use sequoia_cert_store::CertStore; +use crate::types::errors::HuskError; + +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)) => { + match Cert::from_bytes(cert.as_bytes()) { + Ok(c) => Some(c), + Err(_) => None, + } + }, + Some(Err(_)) + | None => None + } + }, + Err(_) => None, + } + }); + + match root { + Some(r) => Ok(r), + None => Err(HuskError::NoLocalTrustRoot.into()) + } + +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index b0f48bd..63d398d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -25,13 +25,15 @@ use indymilter::{ }; use crate::types::husk_context::HuskContext; -use crate::config::HuskConfig; +use crate::config::{HuskConfig, HuskConfigContainer}; pub struct Daemon { } impl Daemon { - pub async fn run(config: &HuskConfig) -> anyhow::Result<()> { + pub async fn run(config_container: HuskConfigContainer) -> anyhow::Result<()> { + + let config: HuskConfig = config_container.clone().into(); let listener = TcpListener::bind(&config.connection) .await @@ -40,7 +42,7 @@ 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(|cx, hostname| Box::pin(Self::handle_helo(cx, hostname))) + .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_rcpt(|cx, args| Box::pin(Self::handle_rcpt(cx, args))) .on_data(|cx| Box::pin(Self::handle_data(cx))) @@ -62,7 +64,7 @@ impl Daemon { } async fn handle_negotiate( - _cx: &mut NegotiateContext, + _cx: &mut NegotiateContext>, actions: Actions, opts: ProtoOpts, ) -> Status { @@ -72,7 +74,7 @@ impl Daemon { } async fn handle_connect( - _cx: &mut Context, + _cx: &mut Context>, hostname: CString, socket_info: SocketInfo, ) -> Status { @@ -82,15 +84,28 @@ impl Daemon { Status::Continue } - async fn handle_helo(_cx: &mut Context, hostname: CString) -> Status { + async fn handle_helo(cx: &mut Context>, config_container: HuskConfigContainer, hostname: CString) -> Status { log::debug!("HELO: hostname: {hostname:?}"); - Status::Continue + let config: HuskConfig = config_container.into(); + + // setup Context + match HuskContext::new(&config) { + Ok(context) => { + cx.data = Some(context).take(); + Status::Continue + }, + Err(e) => { + log::error!("{}", e); + Status::Tempfail + } + } } - async fn handle_mail(cx: &mut Context, args: Vec) -> Status { + async fn handle_mail(cx: &mut Context>, args: Vec) -> Status { log::debug!("MAIL: {args:?}"); + // XXX let sender = match args.first() { Some(cs) => { match cs.to_str() { @@ -101,68 +116,71 @@ impl Daemon { None => { "missing".to_string() } }; - // setup Context - if let Some(mut context) = HuskContext::new() { + if let Some(ref mut context) = cx.data { context.set_sender(sender); - cx.data = Some(context).take(); + Status::Continue + } else { + Status::Tempfail } - - Status::Continue } - async fn handle_rcpt(_cx: &mut Context, args: Vec) -> Status { + async fn handle_rcpt(cx: &mut Context>, args: Vec) -> Status { log::debug!("RCPT: {args:?}"); + if let Some(ref mut context) = cx.data { + + } + Status::Continue } - async fn handle_data(_cx: &mut Context) -> Status { + async fn handle_data(_cx: &mut Context>) -> Status { log::debug!("DATA"); Status::Continue } - async fn handle_header(_cx: &mut Context, name: CString, value: CString) -> Status { + async fn handle_header(_cx: &mut Context>, name: CString, value: CString) -> Status { log::debug!("HEADER: {name:?} = {value:?}"); Status::Continue } - async fn handle_eoh(_cx: &mut Context) -> Status { + async fn handle_eoh(_cx: &mut Context>) -> Status { log::debug!("EOH"); Status::Continue } - async fn handle_body(_cx: &mut Context, chunk: Bytes) -> Status { + async fn handle_body(_cx: &mut Context>, chunk: Bytes) -> Status { log::debug!("BODY: chunk with {:?} bytes received", &chunk.len()); Status::Continue } - async fn handle_eom(cx: &mut EomContext) -> Status { + async fn handle_eom(cx: &mut EomContext>) -> Status { log::debug!("EOM"); - if let Some(ref mut context_data) = cx.data { - log::debug!("Mail from {:?} complete", context_data.sender); + if let Some(ref mut context) = cx.data { + log::debug!("Mail from {:?} complete", context.mail.sender); } Status::Continue } - async fn handle_abort(_cx: &mut Context) -> Status { + async fn handle_abort(_cx: &mut Context>) -> Status { log::debug!("ABORT"); Status::Continue } - async fn handle_close(_cx: &mut Context) -> Status { + async fn handle_close(_cx: &mut Context>) -> Status { log::debug!("CLOSE"); Status::Continue } - async fn handle_unknown(_cx: &mut Context, arg: CString) -> Status { + async fn handle_unknown(_cx: &mut Context>, arg: CString) -> Status { log::debug!("UNKNOWN: {arg:?}"); Status::Continue diff --git a/src/main.rs b/src/main.rs index cf0bfc4..5d156a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,13 @@ // Husk milter // -use std::{ - env, - process, -}; +use std::{env, process}; +use std::sync::{Arc, Mutex}; pub mod config; -use config::HuskConfig; +use config::{HuskConfig, HuskConfigContainer}; pub mod types; - +pub mod crypto; pub mod daemon; use daemon::Daemon; @@ -30,7 +28,9 @@ async fn main() { log::info!("starting..."); - match Daemon::run(&husk_config).await { + let config_container: HuskConfigContainer = Arc::new(Mutex::new(husk_config)); + + match Daemon::run(config_container).await { Ok(_) => { println!("exiting..."); }, diff --git a/src/types/errors.rs b/src/types/errors.rs new file mode 100644 index 0000000..6951951 --- /dev/null +++ b/src/types/errors.rs @@ -0,0 +1,15 @@ +// +// Husk milter +// +// Error typ +// + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum HuskError { + #[error("Cannot find local trust root")] + NoLocalTrustRoot, + #[error("Cannot access cert store")] + NoCertStore +} diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 542d1f0..5cea065 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -4,21 +4,41 @@ // Context // -pub struct HuskContext { - pub sender: Option, +use anyhow; +use std::path::PathBuf; +use sequoia_openpgp::Cert; +use sequoia_cert_store::CertStore; +use sequoia_directories::Home; + +use crate::{config::HuskConfig, crypto}; +use crate::types::mail_context::MailContext; + +pub struct HuskContext<'hc> { + pub cert_store: CertStore<'hc>, + pub local_trust_root: Cert, + pub mail: MailContext<'hc>, } -impl HuskContext { - +impl<'hc> HuskContext<'hc> { + /// Create a new HuskContext instance - pub fn new() -> Option { - Some(HuskContext { - sender: None + pub fn new(config: &HuskConfig) -> anyhow::Result> { + + let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?; + let cert_store_base = sequoia_home.data_dir(sequoia_directories::Component::CertD); + let cert_store = CertStore::open(cert_store_base)?; + + let local_trust_root = crypto::get_local_trust_root(&cert_store)?; + + Ok(HuskContext { + cert_store, + local_trust_root, + mail: MailContext::new(), }) } pub fn set_sender(&mut self, sender: String) { - self.sender = Some(sender); + self.mail.set_sender(sender); } } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs new file mode 100644 index 0000000..498cf6a --- /dev/null +++ b/src/types/mail_context.rs @@ -0,0 +1,37 @@ +// +// Husk milter +// +// Mail Context +// + +use std::ffi::CString; + +use sequoia_openpgp::cert::ValidCert; + +pub struct Recipient<'r> { + pub email: CString, + pub cert: Option>, +} + +pub struct MailContext<'c> { + pub sender: Option, + pub recipients: Vec>, + // header + // body + // body size +} + +impl MailContext<'_> { + + pub fn new() -> Self { + MailContext { + sender: None, + recipients: Vec::new(), + } + } + + pub fn set_sender(&mut self, sender: String) { + self.sender = Some(sender); + } + +} diff --git a/src/types/mod.rs b/src/types/mod.rs index d827538..01c14ea 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1 +1,3 @@ pub mod husk_context; +pub mod mail_context; +pub mod errors; From cc2dfbfcf19f135b35917657329b48dfb928e5eb Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 10 Dec 2025 10:56:09 +0100 Subject: [PATCH 02/12] add missing StoreUpdate --- src/types/husk_context.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 5cea065..bb97bdf 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -7,7 +7,7 @@ use anyhow; use std::path::PathBuf; use sequoia_openpgp::Cert; -use sequoia_cert_store::CertStore; +use sequoia_cert_store::{CertStore, StoreUpdate}; use sequoia_directories::Home; use crate::{config::HuskConfig, crypto}; From 69038177fe491dec545656ef188390bfece340a3 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 10 Dec 2025 11:08:16 +0100 Subject: [PATCH 03/12] switched to local build image --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c77f140..c1d06a8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ stages: build-job: stage: build - image: rust:latest + image: localhost:5000/huskbuilder:latest script: - cargo build From 49c1b99b5a76e49a418bcfe3a90572a0436f9d2c Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 10 Dec 2025 11:14:07 +0100 Subject: [PATCH 04/12] switched to local build image - for the tests --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c1d06a8..8b5b3b7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,7 +10,7 @@ build-job: test-job: stage: test - image: rust:latest + image: localhost:5000/huskbuilder:latest script: - cargo test From 23c698f4451dc9c33f25e1eaa27d100841e14fac Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Thu, 11 Dec 2025 11:53:55 +0100 Subject: [PATCH 05/12] Start collecting recipients - Collect recipients for processed email. - Add proper email parsing --- src/daemon/mod.rs | 42 +++++++++++++++++++++++++++------------ src/mail.rs | 22 ++++++++++++++++++++ src/main.rs | 1 + src/types/husk_context.rs | 13 ++++++++++++ src/types/mail_context.rs | 22 ++++++++++++++++++-- 5 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 src/mail.rs diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 63d398d..d552ad9 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -26,6 +26,7 @@ use indymilter::{ use crate::types::husk_context::HuskContext; use crate::config::{HuskConfig, HuskConfigContainer}; +use crate::mail; pub struct Daemon { } @@ -105,19 +106,19 @@ impl Daemon { async fn handle_mail(cx: &mut Context>, args: Vec) -> Status { log::debug!("MAIL: {args:?}"); - // XXX - let sender = match args.first() { - Some(cs) => { - match cs.to_str() { - Ok(s) => s.to_string(), - _ => "corrupt".to_string() - } - }, - None => { "missing".to_string() } - }; + let mut sender: Option = None; + + for arg in args { + if let Some(s) = mail::to_email(&arg) { + sender = Some(s); + break; + } + } + if sender.is_none() { return Status::Tempfail; } if let Some(ref mut context) = cx.data { - context.set_sender(sender); + context.reset_mail(); + context.set_sender(sender.unwrap()); Status::Continue } else { Status::Tempfail @@ -129,9 +130,23 @@ impl Daemon { if let Some(ref mut context) = cx.data { + let mut rcpt: Option = None; + for arg in args { + if let Some(s) = mail::to_email(&arg) { + rcpt = Some(s); + break; + } + } + if rcpt.is_none() { + Status::Tempfail + } else { + context.add_recipient(rcpt.unwrap()).unwrap(); + Status::Continue + } + + } else { + Status::Tempfail } - - Status::Continue } async fn handle_data(_cx: &mut Context>) -> Status { @@ -163,6 +178,7 @@ impl Daemon { if let Some(ref mut context) = cx.data { log::debug!("Mail from {:?} complete", context.mail.sender); + log::debug!("recipients: {:?}", context.mail.recipients); } Status::Continue diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..96ba6c7 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,22 @@ +// +// Husk milter +// +// mail functions +// + +use std::ffi::CString; +use sequoia_openpgp::packet::UserID; + +pub fn to_userid(rcpt: &CString) -> Option { + match rcpt.to_str() { + Ok(s) => Some(UserID::from(s)), + _ => None + } +} + +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()) } + else { None } + } else { None } +} diff --git a/src/main.rs b/src/main.rs index 5d156a0..f7ad76d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ pub mod config; use config::{HuskConfig, HuskConfigContainer}; pub mod types; pub mod crypto; +pub mod mail; pub mod daemon; use daemon::Daemon; diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index bb97bdf..dab8da5 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -5,6 +5,7 @@ // use anyhow; +use sequoia_openpgp::policy::StandardPolicy; use std::path::PathBuf; use sequoia_openpgp::Cert; use sequoia_cert_store::{CertStore, StoreUpdate}; @@ -14,6 +15,7 @@ use crate::{config::HuskConfig, crypto}; use crate::types::mail_context::MailContext; pub struct HuskContext<'hc> { + pub policy: StandardPolicy<'hc>, pub cert_store: CertStore<'hc>, pub local_trust_root: Cert, pub mail: MailContext<'hc>, @@ -28,9 +30,12 @@ impl<'hc> HuskContext<'hc> { let cert_store_base = sequoia_home.data_dir(sequoia_directories::Component::CertD); let cert_store = CertStore::open(cert_store_base)?; + let policy = StandardPolicy::new(); + let local_trust_root = crypto::get_local_trust_root(&cert_store)?; Ok(HuskContext { + policy, cert_store, local_trust_root, mail: MailContext::new(), @@ -41,4 +46,12 @@ impl<'hc> HuskContext<'hc> { self.mail.set_sender(sender); } + pub fn reset_mail(&mut self) { + self.mail = MailContext::new(); + } + + pub fn add_recipient(&mut self, rcpt: String) -> anyhow::Result<()> { + self.mail.add_recipient(rcpt) + } + } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 498cf6a..f60c8ec 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -4,15 +4,25 @@ // Mail Context // -use std::ffi::CString; +use anyhow; use sequoia_openpgp::cert::ValidCert; +#[derive(Debug, Clone)] pub struct Recipient<'r> { - pub email: CString, + pub email: String, pub cert: Option>, } +impl From for Recipient<'_> { + fn from(email: String) -> Self { + Recipient { + email: email, + cert: None + } + } +} + pub struct MailContext<'c> { pub sender: Option, pub recipients: Vec>, @@ -34,4 +44,12 @@ impl MailContext<'_> { self.sender = Some(sender); } + pub fn add_recipient(&mut self, rcpt: String) -> anyhow::Result<()> { + let recipient: Recipient = rcpt.into(); + + self.recipients.push(recipient); + + Ok(()) + } + } From dd71b894851589f5f3446e45e99c1734ca5aef2c Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Thu, 11 Dec 2025 16:03:34 +0100 Subject: [PATCH 06/12] Add certificates - Refactor `Recipient` out of `MailContext`. - Add lookup of authenticated certificates for recipients. --- Cargo.toml | 1 + src/crypto.rs | 39 ++++++++++++++++++++++++++++++++++++++- src/types/husk_context.rs | 5 ++++- src/types/mail_context.rs | 31 +++++++++---------------------- src/types/mod.rs | 1 + src/types/recipient.rs | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 src/types/recipient.rs diff --git a/Cargo.toml b/Cargo.toml index 7bf02a2..41d11bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ log4rs = "1.3.0" sequoia-cert-store = "0.7.1" sequoia-directories = "0.1.0" sequoia-openpgp = "2.1.0" +sequoia-wot = "0.15.0" serde = "1.0.228" serde_derive = "1.0.228" thiserror = "2.0.17" diff --git a/src/crypto.rs b/src/crypto.rs index ef55fb6..2c84748 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -4,12 +4,18 @@ // cryptographic functions // +use std::sync::Arc; + use anyhow; use sequoia_openpgp::parse::Parse; use sequoia_openpgp::{Fingerprint, Cert}; use sequoia_openpgp::cert::raw::RawCertParser; -use sequoia_cert_store::CertStore; +use sequoia_cert_store::{Store, CertStore, LazyCert}; +use sequoia_wot::{self as wot}; +use wot::store::Store as _; + use crate::types::errors::HuskError; +use crate::types::husk_context::HuskContext; pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { @@ -45,3 +51,34 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { } } + +pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) + -> Vec>> { + + let cert_store = &context.cert_store; + let trust_roots = vec![(context.local_trust_root.fingerprint(), wot::FULLY_TRUSTED)]; + let wot_store = wot::store::CertStore::from_store(cert_store, &context.policy, None); + let nb = wot::NetworkBuilder::rooted(&wot_store, &*trust_roots); + let n = nb.build(); + + let bindings: Vec<_> = n.lookup_synopses_by_email(email) + .into_iter() + .map(|(fp, userid)| (fp, Some(userid))) + .collect(); + + let mut result = vec![]; + for (fpr, some_userid) in bindings { + if let Some(userid) = some_userid { + let paths = n.authenticate(userid, &fpr, wot::FULLY_TRUSTED); + if paths.len() > 0 { + log::debug!("authenticated!!!"); + for (path, _) in paths.iter() { + if let Ok(cert) = cert_store.lookup_by_cert_fpr(&path.target().fingerprint()) { + result.push(cert); + } + } + } + } + } + result +} diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index dab8da5..234d524 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -51,7 +51,10 @@ impl<'hc> HuskContext<'hc> { } pub fn add_recipient(&mut self, rcpt: String) -> anyhow::Result<()> { - self.mail.add_recipient(rcpt) + + let certs = crypto::get_certificates(&self, rcpt.as_str()); + + self.mail.add_recipient(rcpt, certs) } } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index f60c8ec..877f55a 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -4,34 +4,20 @@ // Mail Context // -use anyhow; +use std::sync::Arc; +use sequoia_cert_store::LazyCert; -use sequoia_openpgp::cert::ValidCert; +use crate::types::recipient::Recipient; -#[derive(Debug, Clone)] -pub struct Recipient<'r> { - pub email: String, - pub cert: Option>, -} - -impl From for Recipient<'_> { - fn from(email: String) -> Self { - Recipient { - email: email, - cert: None - } - } -} - -pub struct MailContext<'c> { +pub struct MailContext<'mc> { pub sender: Option, - pub recipients: Vec>, + pub recipients: Vec>, // header // body // body size } -impl MailContext<'_> { +impl<'mc> MailContext<'mc> { pub fn new() -> Self { MailContext { @@ -44,8 +30,9 @@ impl MailContext<'_> { self.sender = Some(sender); } - pub fn add_recipient(&mut self, rcpt: String) -> anyhow::Result<()> { - let recipient: Recipient = rcpt.into(); + pub fn add_recipient(&mut self, rcpt: String, certs: Vec>>) -> anyhow::Result<()> { + let mut recipient: Recipient = rcpt.into(); + recipient.certs = certs; self.recipients.push(recipient); diff --git a/src/types/mod.rs b/src/types/mod.rs index 01c14ea..4ba6c8e 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,3 +1,4 @@ pub mod husk_context; pub mod mail_context; pub mod errors; +pub mod recipient; diff --git a/src/types/recipient.rs b/src/types/recipient.rs new file mode 100644 index 0000000..0d93a03 --- /dev/null +++ b/src/types/recipient.rs @@ -0,0 +1,39 @@ +// +// Husk milter +// +// Recipient +// + +use anyhow; +use std::sync::Arc; +use std::ffi::CString; +use sequoia_cert_store::LazyCert; + +#[derive(Debug, Clone)] +pub struct Recipient<'r> { + pub email: String, + pub certs: Vec>> +} + +impl From for Recipient<'_> { + fn from(email: String) -> Self { + Recipient { + email: email, + certs: Vec::new() + } + } +} + +impl TryFrom for Recipient<'_> { + + type Error=anyhow::Error; + + fn try_from(value: CString) -> Result { + match value.into_string() { + Ok(s) => Ok(s.into()), + Err(e) => Err(e.into()), + } + } +} + + From e56eb8eb9adadb20e0979f49ebf5cadc3b0574d1 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Mon, 15 Dec 2025 12:27:20 +0100 Subject: [PATCH 07/12] Add introducers - add the `Introducer` types - get introducers from the certificate store (and log them) --- src/crypto.rs | 45 ++++++++++++++++++++++++++++++++++++++- src/daemon/mod.rs | 13 +++++++++-- src/types/husk_context.rs | 5 +++++ src/types/introducer.rs | 33 ++++++++++++++++++++++++++++ src/types/mod.rs | 1 + 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/types/introducer.rs diff --git a/src/crypto.rs b/src/crypto.rs index 2c84748..5a6f2a6 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -8,14 +8,17 @@ use std::sync::Arc; use anyhow; use sequoia_openpgp::parse::Parse; +use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::{Fingerprint, Cert}; use sequoia_openpgp::cert::raw::RawCertParser; use sequoia_cert_store::{Store, CertStore, LazyCert}; use sequoia_wot::{self as wot}; use wot::store::Store as _; +use wot::{Depth, Path}; use crate::types::errors::HuskError; use crate::types::husk_context::HuskContext; +use crate::types::introducer::Introducer; pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { @@ -71,7 +74,7 @@ pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) if let Some(userid) = some_userid { let paths = n.authenticate(userid, &fpr, wot::FULLY_TRUSTED); if paths.len() > 0 { - log::debug!("authenticated!!!"); + log::debug!("{} authenticated!!!", email); for (path, _) in paths.iter() { if let Ok(cert) = cert_store.lookup_by_cert_fpr(&path.target().fingerprint()) { result.push(cert); @@ -82,3 +85,43 @@ pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) } result } + +pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local_trust_root: Fingerprint) -> Vec { + + let mut result = Vec::new(); + + let trust_roots = vec![(local_trust_root, wot::FULLY_TRUSTED)]; + let wot_store = wot::store::CertStore::from_store(cert_store, policy, None); + let n = wot::NetworkBuilder::rooted(&wot_store, &*trust_roots) + .build(); + + n.certified_userids().iter().for_each(|(fpr, user_id)| { + let paths = n.authenticate(user_id, fpr, wot::FULLY_TRUSTED); + + // find the longest path (highest trust depth) + let mut depth = Depth::Limit(0); + let mut target: Option<&Path> = None; + paths.iter().for_each(|(path, amount)| { + if *amount == wot::FULLY_TRUSTED { + let d = path.residual_depth(); + if d > depth { + depth = d; + target = Some(path); + } + } + }); + + if depth > Depth::Limit(0) { + if let Some(p) = target { + if let Some(c) = p.certifications().last() { + result.push( Introducer { + cert: p.target().clone(), + certification: c.clone(), + }); + } + } + } + }); + + result +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index d552ad9..90ac1a9 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -93,6 +93,7 @@ impl Daemon { // setup Context match HuskContext::new(&config) { Ok(context) => { + log::debug!("found introducers: {:?}", context.introducers); cx.data = Some(context).take(); Status::Continue }, @@ -149,10 +150,18 @@ impl Daemon { } } - async fn handle_data(_cx: &mut Context>) -> Status { + async fn handle_data(cx: &mut Context>) -> Status { log::debug!("DATA"); - Status::Continue + 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 { diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 234d524..5e6e7e6 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -14,10 +14,13 @@ use sequoia_directories::Home; use crate::{config::HuskConfig, crypto}; use crate::types::mail_context::MailContext; +use crate::types::introducer::Introducer; + pub struct HuskContext<'hc> { pub policy: StandardPolicy<'hc>, pub cert_store: CertStore<'hc>, pub local_trust_root: Cert, + pub introducers: Vec, pub mail: MailContext<'hc>, } @@ -33,11 +36,13 @@ impl<'hc> HuskContext<'hc> { let policy = StandardPolicy::new(); let local_trust_root = crypto::get_local_trust_root(&cert_store)?; + let introducers = crypto::get_introducers(&cert_store, &policy, local_trust_root.fingerprint()); Ok(HuskContext { policy, cert_store, local_trust_root, + introducers, mail: MailContext::new(), }) } diff --git a/src/types/introducer.rs b/src/types/introducer.rs new file mode 100644 index 0000000..fe0e349 --- /dev/null +++ b/src/types/introducer.rs @@ -0,0 +1,33 @@ +// +// husk-milter +// +// introducers +// + +use sequoia_wot as wot; +use wot::{CertSynopsis, Certification}; +use sequoia_openpgp::{policy::StandardPolicy, Cert, KeyID}; + +#[derive(Debug, Clone)] +pub struct Introducer { + pub cert: CertSynopsis, + pub certification: Certification, +} + +impl Introducer { + + pub fn can_introduce(&self, email: &str) -> bool { + if let Some(regexset) = self.certification.regular_expressions() { + if regexset.matches_everything() { + // return false, because unlimited certifications are + // ignored. + false + } else { + regexset.is_match(email) + } + } else { + false + } + } +} + diff --git a/src/types/mod.rs b/src/types/mod.rs index 4ba6c8e..16cda95 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -2,3 +2,4 @@ pub mod husk_context; pub mod mail_context; pub mod errors; pub mod recipient; +pub mod introducer; From 9b41457b8ebb889e7a1719dea03f3aaba00abc9c Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Tue, 16 Dec 2025 12:04:26 +0100 Subject: [PATCH 08/12] Check for encryption - collect body of incoming mail. - check if incoming mail is already encrypted - dont touch it if it is. - check if recipients of that mail can encrypt. --- src/crypto.rs | 35 ++++++++++++++++++++++--- src/daemon/mod.rs | 55 ++++++++++++++++++++++++++++++--------- src/types/mail_context.rs | 50 +++++++++++++++++++++++++++++++++-- src/types/recipient.rs | 9 +++++++ 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index 5a6f2a6..2f13194 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -7,9 +7,10 @@ use std::sync::Arc; use anyhow; -use sequoia_openpgp::parse::Parse; +use bytes::Bytes; +use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::policy::StandardPolicy; -use sequoia_openpgp::{Fingerprint, Cert}; +use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::cert::raw::RawCertParser; use sequoia_cert_store::{Store, CertStore, LazyCert}; use sequoia_wot::{self as wot}; @@ -55,7 +56,7 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { } -pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) +pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) -> Vec>> { let cert_store = &context.cert_store; @@ -125,3 +126,31 @@ pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, result } + +pub fn is_encrypted(body: &Bytes) -> bool { + + let mut ppr = match PacketParser::from_bytes(body) { + Ok(pp) => pp, + Err(_) => return false, // no OpenPGP artifacts + }; + + let mut encrypted: bool = false; + + while let PacketParserResult::Some(pp) = ppr { + if let Packet::SEIP(_) = pp.packet { + encrypted = true; + } + + // parse the whole message, don't stop at first SEIP + ppr = match pp.recurse() { + Ok((_, next_ppr)) => next_ppr, + Err(_) => return false, // broken artifact -> not encrypted + } + } + if let PacketParserResult::EOF(eof) = ppr { + if eof.is_message().is_ok() && encrypted { + return true; + } + } + false +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 90ac1a9..47518b5 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -17,16 +17,18 @@ use indymilter::{ EomContext, Macros, NegotiateContext, - ProtoOpts, + ProtoOpts, SocketInfo, - Status, - Config, + Status, + Config, ContextActions, }; use crate::types::husk_context::HuskContext; use crate::config::{HuskConfig, HuskConfigContainer}; use crate::mail; +use crate::crypto; +use crate::types::mail_context::ProtectionPossibility; pub struct Daemon { } @@ -101,7 +103,7 @@ impl Daemon { log::error!("{}", e); Status::Tempfail } - } + } } async fn handle_mail(cx: &mut Context>, args: Vec) -> Status { @@ -110,7 +112,7 @@ impl Daemon { let mut sender: Option = None; for arg in args { - if let Some(s) = mail::to_email(&arg) { + if let Some(s) = mail::to_email(&arg) { sender = Some(s); break; } @@ -133,18 +135,18 @@ impl Daemon { let mut rcpt: Option = None; for arg in args { - if let Some(s) = mail::to_email(&arg) { + if let Some(s) = mail::to_email(&arg) { rcpt = Some(s); break; } } - if rcpt.is_none() { - Status::Tempfail + if rcpt.is_none() { + Status::Tempfail } else { context.add_recipient(rcpt.unwrap()).unwrap(); Status::Continue } - + } else { Status::Tempfail } @@ -153,7 +155,7 @@ impl Daemon { async fn handle_data(cx: &mut Context>) -> Status { log::debug!("DATA"); - if let Some(ref mut context) = cx.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 @@ -176,10 +178,15 @@ impl Daemon { Status::Continue } - async fn handle_body(_cx: &mut Context>, chunk: Bytes) -> Status { + async fn handle_body(cx: &mut Context>, chunk: Bytes) -> Status { log::debug!("BODY: chunk with {:?} bytes received", &chunk.len()); - Status::Continue + if let Some(ref mut context) = cx.data { + context.mail.add_chunk(chunk); + Status::Continue + } else { + Status::Tempfail + } } async fn handle_eom(cx: &mut EomContext>) -> Status { @@ -188,6 +195,30 @@ impl Daemon { if let Some(ref mut context) = cx.data { log::debug!("Mail from {:?} complete", context.mail.sender); log::debug!("recipients: {:?}", context.mail.recipients); + log::debug!("body size: {}", context.mail.body_size); + + let body = context.mail.collect_body(); + + // check if mail is already encrypted. If it is, keep it as such. + if crypto::is_encrypted(&body) { + log::info!("encrypted mail detected"); + return Status::Accept; + } + + + // possible protection + // none -> accept + // partial -> accept (later: split) + // full -> encrypt + // check mailbody + // is encrypted -> accept + // else -> encrypt + + let protection = ProtectionPossibility::from(&context.mail); + + if protection == ProtectionPossibility::Full { + // encrypt + } } Status::Continue diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 877f55a..08c9d2c 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -5,16 +5,25 @@ // use std::sync::Arc; +use bytes::{Bytes, BytesMut}; use sequoia_cert_store::LazyCert; use crate::types::recipient::Recipient; +#[derive(PartialEq)] +pub enum ProtectionPossibility { + NoProtection, + Partial, + Full +} + + pub struct MailContext<'mc> { pub sender: Option, pub recipients: Vec>, // header - // body - // body size + body: Vec, + pub body_size: usize, } impl<'mc> MailContext<'mc> { @@ -23,6 +32,8 @@ impl<'mc> MailContext<'mc> { MailContext { sender: None, recipients: Vec::new(), + body: Vec::new(), + body_size: 0, } } @@ -39,4 +50,39 @@ impl<'mc> MailContext<'mc> { Ok(()) } + pub fn add_chunk(&mut self, chunk: Bytes) { + self.body_size += chunk.len(); + self.body.push(chunk); + } + + pub fn collect_body(&self) -> Bytes { + let mut buffer = BytesMut::with_capacity(self.body_size); + for chunk in &self.body { + buffer.extend_from_slice(&chunk[..]); + } + buffer.into() + } + +} + +impl From<&MailContext<'_>> for ProtectionPossibility { + fn from(mail_context: &MailContext<'_>) -> ProtectionPossibility { + let mut can_encrypt = false; + let mut cannot_encrypt = false; + + for rcpt in mail_context.recipients.iter() { + if rcpt.can_encrypt() { + can_encrypt = true; + } else { + cannot_encrypt = true; + } + } + + match (can_encrypt, cannot_encrypt) { + (true, false) => ProtectionPossibility::Full, + (true, true) => ProtectionPossibility::Partial, + (false, true) => ProtectionPossibility::NoProtection, + (false, false) => ProtectionPossibility::NoProtection, // this should not occure + } + } } diff --git a/src/types/recipient.rs b/src/types/recipient.rs index 0d93a03..0c806b8 100644 --- a/src/types/recipient.rs +++ b/src/types/recipient.rs @@ -36,4 +36,13 @@ impl TryFrom for Recipient<'_> { } } +impl Recipient<'_> { + pub fn can_encrypt(&self) -> bool { + if self.certs.len() == 0 { + return false; + } + true + } +} + From 64312da01d4afe1e1a5e0970644d2ef624cca07b Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Tue, 16 Dec 2025 12:29:14 +0100 Subject: [PATCH 09/12] Add tests for is_encrypted() - add some tests to ensure that is_encrypted() works correctly. --- src/crypto.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/crypto.rs b/src/crypto.rs index 2f13194..67dae6b 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -154,3 +154,90 @@ pub fn is_encrypted(body: &Bytes) -> bool { } false } + +#[cfg(test)] +pub mod tests { + use bytes::Bytes; + +#[test] + pub fn test_is_encrypted() { + + use crate::crypto::is_encrypted; + + // cleartext + let body = b"Hello world"; + let body = Bytes::from(&body[..]); + assert!(! is_encrypted(&body)); + + // a valid encrypted message + let body = b"-----BEGIN PGP MESSAGE----- + +wWwGFQRhclhA/Qg3CiL81NAswyOWkyk8jxIBB0AmlBkVEVbQst60C/1+mcmIxLbG +m0w+qv2aP5NQhAetCzDgA/KEhtim0Ebzf06jJG7bITPkYmfN2mizdChgv1f3y2eQ +80hYpgbD930RtKXTD1TSWgIJAgai2MelZrDAQXIaFKlRRY9u7R1YIXQmMa6uDced +W/g9mJDd0ral30VL0FfuKRR2Je2TRfizqMb4tL4EIRFqrfSBesb9WcniEfBekG3B +jk6lh01Ej9QTGQ== +-----END PGP MESSAGE----- +"; + + let body = Bytes::from(&body[..]); + assert!(is_encrypted(&body)); + + // a PUBLIC KEY BLOCK is an artifact, but it's not encrypted + let body = b"-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEaUE/aRYJKwYBBAHaRw8BAQdAXScnsTjHmy1yZ/SmddPq7tmY4SZwsyWmRNei +EX60UJDCwBEEHxYKAIMFgmlBP2kFiQWkj70DCwkHCRC9/0MioyzN/0cUAAAAAAAe +ACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmfUHGvbmmp7vFwpMzGj36fZ +Px+taozYHxyGljxsJnlV3AMVCggCmwECHgkWIQSQO2vdjfwfK4J6FaK9/0MioyzN +/wAATokBAP9abr1BeV2qrvwF9TsQDlvgnaNPtzhPy00oDi17/8x5AP99DLK8hAJ3 +FtejeAJ0/2+BfUZzRlF8tlSGe5/Iln9DCM0TPGFsaWNlQGV4YW1wbGUuY29tPsLA +FAQTFgoAhgWCaUE/aQWJBaSPvQMLCQcJEL3/QyKjLM3/RxQAAAAAAB4AIHNhbHRA +bm90YXRpb25zLnNlcXVvaWEtcGdwLm9yZyWqUJWT+0+sGq1ci9IkqHvnsUN+c5LR +B921ZbGlCwLXAxUKCAKZAQKbAQIeCRYhBJA7a92N/B8rgnoVor3/QyKjLM3/AAB5 ++AEAvMCVVynlwsaFQvACga9NTfyZGQ7IjhFhjbCXY+CZbHoA/0FaajzhUF6mKQH7 +RMwMK6Dp0YD2A34/DGb5KbDkLjIOzjMEaUE/aRYJKwYBBAHaRw8BAQdAd4Y3kt2s +NszsrkMELS0FC2viTw+NEU0ZnAT/aD5eydLCwMUEGBYKATcFgmlBP2kFiQWkj70J +EL3/QyKjLM3/RxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdwLm9y +Z1R29gVOHk9hllft18uHrwEV0MgvzykG3yWKF1B+vT9hApsCvqAEGRYKAG8FgmlB +P2kJEHx7uc2lNPhjRxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdw +Lm9yZ9Q8ycbljQVzg2YapbQX+IJ9H+1KSgSLXQtVtCsaJh8RFiEEerJu6BkdLdse +21ngfHu5zaU0+GMAAD3BAQCzmPr7q3Wf1BFKO95WBhe66Cg+lAnoJZKbgHBpUsIB +6wEAmGzOcL1zYXRBWpquzFKS2bNxuFdpbewkxkf0axvMnA8WIQSQO2vdjfwfK4J6 +FaK9/0MioyzN/wAAoD8A/2a7+DrKEjL7fAt7UZnPYZOXPEOpYXxUE3ZGQbnsKrdw +AP0ae/B4LYXd89UwHpQUBEJHbY113W8NRUfpM/EJUQRdBM4zBGlBP2kWCSsGAQQB +2kcPAQEHQNhqbQtL5hS4w0n9H0hHMiD84WY39ukclu1yCtBii/+xwsDFBBgWCgE3 +BYJpQT9pBYkFpI+9CRC9/0MioyzN/0cUAAAAAAAeACBzYWx0QG5vdGF0aW9ucy5z +ZXF1b2lhLXBncC5vcmd6G9Mcoj01NqY/TIgPlhe4L5OMUS/R4cIc2wWl2EmQTAKb +IL6gBBkWCgBvBYJpQT9pCRAR5pEu+j17CUcUAAAAAAAeACBzYWx0QG5vdGF0aW9u +cy5zZXF1b2lhLXBncC5vcmeJ47SGs3Kf1A20PTM/BFkWz2zd2dfaiVx8zKNbXoEZ +9BYhBK5UVQP/gdjl2RIb3RHmkS76PXsJAACjYgD/SfQ8WoCnBDnywpVknkW8FtHd +akewRjkT17Q5gK1NEWUA/jOMj8chhN4R6jnnWEb0ClujkkVMBWP7h2N5HWGN4DoD +FiEEkDtr3Y38HyuCehWivf9DIqMszf8AAC7NAQCNZWDduo/vseZGLDNOK9y+krFZ +fDSGVBmvBHGiGjX6agD/SHcsROm0C/HXe+KxS/SAhHob2KdZ8FQ+dCtr3oY/egXO +OARpQT9pEgorBgEEAZdVAQUBAQdA1dxpaBPWnqr8lhzucvP2F746IIdfYk+UVImg +pe3GD1UDAQgHwsAGBBgWCgB4BYJpQT9pBYkFpI+9CRC9/0MioyzN/0cUAAAAAAAe +ACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmeDJU2fBZAzwn32lcgdEyjB +/LtqMEQ7Qv5HwfJFRlseCAKbDBYhBJA7a92N/B8rgnoVor3/QyKjLM3/AADjHQEA +yvN5w7wxf50yriNlrHCwRzQ4MljfJawXfAfOthJlh8gBANycfude13YmUeFvwuyQ +Vmac4mRS69ivzHMLDNXUsMwB +=WPEh +-----END PGP PUBLIC KEY BLOCK----- +"; + let body = Bytes::from(&body[..]); + assert!(! is_encrypted(&body)); + + // broken message + let body = b"-----BEGIN PGP MESSAGE----- + +wWwGFQRhclhA/Qg3CiL81NAswyOWkyk8jxIBB0AmlBkVEVbQst60C/1+mcmIxLbG +W/g9mJDd0ral30VL0FfuKRR2Je2TRfizqMb4tL4EIRFqrfSBesb9WcniEfBekG3B +jk6lh01Ej9QTGQ== +-----END PGP MESSAGE----- +"; + let body = Bytes::from(&body[..]); + assert!(! is_encrypted(&body)); + + } + +} From eeda63f5ab3ae12b1fe30de89db613585aa31cff Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 17 Dec 2025 11:11:28 +0100 Subject: [PATCH 10/12] Add encryption - actually encrypt the body of a mail --- src/crypto.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/daemon/mod.rs | 15 +++++++++++---- src/types/errors.rs | 4 +++- src/types/mail_context.rs | 4 ++-- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index 67dae6b..2d9d7f7 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -4,12 +4,14 @@ // cryptographic functions // +use std::io::Write; use std::sync::Arc; use anyhow; use bytes::Bytes; use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::policy::StandardPolicy; +use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, LiteralWriter}; use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::cert::raw::RawCertParser; use sequoia_cert_store::{Store, CertStore, LazyCert}; @@ -20,6 +22,7 @@ use wot::{Depth, Path}; use crate::types::errors::HuskError; use crate::types::husk_context::HuskContext; use crate::types::introducer::Introducer; +use crate::types::recipient::Recipient; pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { @@ -155,6 +158,42 @@ pub fn is_encrypted(body: &Bytes) -> bool { false } +pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec) + -> anyhow::Result { + + let mut keys = Vec::new(); + + // XXX: handle the case where a certificate misses an encryption subkey + for rcpt in recipients { + if rcpt.can_encrypt() { + for cert in &rcpt.certs { + cert.with_policy(&context.policy, None)? + .keys() + .supported() + .alive() + .revoked(false) + .for_transport_encryption() + .for_each(|k| { + keys.push(k); + }); + } + } + } + if keys.len() > 0 { + let mut sink = Vec::new(); + let message = Message::new(&mut sink); + let message = Armorer::new(message).build()?; + let message = Encryptor::for_recipients(message, keys).build()?; + let mut message = LiteralWriter::new(message).build()?; + message.write_all(&body[..])?; + message.finalize()?; + + Ok(Bytes::from(sink)) + } else { + Err(HuskError::NoEncryptionKeys.into()) + } +} + #[cfg(test)] pub mod tests { use bytes::Bytes; diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 47518b5..eb82b9e 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -210,14 +210,21 @@ impl Daemon { // none -> accept // partial -> accept (later: split) // full -> encrypt - // check mailbody - // is encrypted -> accept - // else -> encrypt let protection = ProtectionPossibility::from(&context.mail); if protection == ProtectionPossibility::Full { - // encrypt + // if an error occures the body is not exchanged + match crypto::encrypt(&context, &body, &context.mail.recipients) { + Ok(encrypted) => { + if cx.actions.replace_body(&encrypted).await.is_err() { + log::error!("Cannot exchange body"); + } + }, + Err(e) => { + log::error!("{}", e); + } + } } } diff --git a/src/types/errors.rs b/src/types/errors.rs index 6951951..7562952 100644 --- a/src/types/errors.rs +++ b/src/types/errors.rs @@ -11,5 +11,7 @@ pub enum HuskError { #[error("Cannot find local trust root")] NoLocalTrustRoot, #[error("Cannot access cert store")] - NoCertStore + NoCertStore, + #[error("No encryption keys found")] + NoEncryptionKeys, } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 08c9d2c..6586af8 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -23,7 +23,7 @@ pub struct MailContext<'mc> { pub recipients: Vec>, // header body: Vec, - pub body_size: usize, + pub body_size: usize, } impl<'mc> MailContext<'mc> { @@ -46,7 +46,7 @@ impl<'mc> MailContext<'mc> { recipient.certs = certs; self.recipients.push(recipient); - + Ok(()) } From a56e933e801a2667bce234a24c6f16c42995e5ea Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 17 Dec 2025 12:26:09 +0100 Subject: [PATCH 11/12] Add end-to-end test to CI - use new MTA image and an initial test --- .gitlab-ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b5b3b7..09d43fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,12 +1,6 @@ stages: - - build - test - -build-job: - stage: build - image: localhost:5000/huskbuilder:latest - script: - - cargo build + - e2e test-job: stage: test @@ -14,3 +8,10 @@ test-job: script: - cargo test +e2e-job: + stage: e2e + image: localhost:5000/mta:latest + script: + - bash /usr/bin/prepare.sh $CI_COMMIT_BRANCH + - bash /usr/bin/test_1.sh + From b7e01a446cfb8022ea6a8be799abeb83816bddc5 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Mon, 5 Jan 2026 12:32:30 +0100 Subject: [PATCH 12/12] Refactoring loading of config file - let `HuskConfig::load` return a `Result` instead of an `Option`. - added comments --- src/config.rs | 13 +++++++------ src/crypto.rs | 8 ++++++++ src/main.rs | 14 +++++++------- src/types/errors.rs | 4 ++++ src/types/introducer.rs | 2 +- src/types/mail_context.rs | 6 +++++- src/types/recipient.rs | 4 ++++ 7 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/config.rs b/src/config.rs index 987db39..13741d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,10 +2,13 @@ // Parsing the config file // +use anyhow; use std::fs; use std::sync::{Arc, Mutex}; use serde_derive::Deserialize; +use crate::types::errors::HuskError; + #[derive(Deserialize, Debug, Clone)] pub struct HuskConfig { pub connection: String, @@ -15,20 +18,18 @@ pub struct HuskConfig { impl HuskConfig { - pub fn load(path: &String) -> Option { + pub fn load(path: &String) -> anyhow::Result { match fs::read_to_string(path) { Ok(data) => { match toml::from_str(data.as_str()) { - Ok(configuration) => Some(configuration), + Ok(configuration) => Ok(configuration), Err(e) => { - eprintln!("Error while parsing config file: {:?}", e); - None + Err(HuskError::ConfigFileParseError(e.to_string()).into()) } } }, Err(e) => { - eprintln!("Error while reading config file: {:?}", e); - None + Err(HuskError::ConfigFileOpenError(e.to_string()).into()) } } } diff --git a/src/crypto.rs b/src/crypto.rs index 2d9d7f7..55e1cac 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -24,6 +24,8 @@ use crate::types::husk_context::HuskContext; use crate::types::introducer::Introducer; use crate::types::recipient::Recipient; +/// 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() @@ -59,6 +61,9 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { } +/// Returns all certificates with an authenticated binding to user id 'email'. +/// The certificates are not checked for usefulness (for instance, they might +/// miss an encryption subkey). pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) -> Vec>> { @@ -90,6 +95,7 @@ pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) result } +/// Returns all introducers available in cert_store. pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local_trust_root: Fingerprint) -> Vec { let mut result = Vec::new(); @@ -130,6 +136,7 @@ pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, result } +/// Checks if a message contains the OpenPGP artifacts of an encrypted message. pub fn is_encrypted(body: &Bytes) -> bool { let mut ppr = match PacketParser::from_bytes(body) { @@ -158,6 +165,7 @@ pub fn is_encrypted(body: &Bytes) -> bool { false } +/// Encrypt a message for `recipients`. pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec) -> anyhow::Result { diff --git a/src/main.rs b/src/main.rs index f7ad76d..4c0c09e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,13 +17,13 @@ use daemon::Daemon; async fn main() { let config_file = env::args().nth(1).expect("config file missing"); - let husk_config = HuskConfig::load(&config_file); - - if husk_config.is_none() { - eprintln!("Cannot load configuration"); - process::exit(1); - } - let husk_config = husk_config.unwrap(); + let husk_config = match HuskConfig::load(&config_file) { + Ok(config) => config, + Err(e) => { + eprintln!("{:?}", e); + process::exit(1); + } + }; log4rs::init_file(husk_config.logfile_config(), Default::default()).unwrap(); diff --git a/src/types/errors.rs b/src/types/errors.rs index 7562952..fce16a4 100644 --- a/src/types/errors.rs +++ b/src/types/errors.rs @@ -8,6 +8,10 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum HuskError { + #[error("Cannot open config file: {0}")] + ConfigFileOpenError(String), + #[error("Error while parsing config file: {0}")] + ConfigFileParseError(String), #[error("Cannot find local trust root")] NoLocalTrustRoot, #[error("Cannot access cert store")] diff --git a/src/types/introducer.rs b/src/types/introducer.rs index fe0e349..f194199 100644 --- a/src/types/introducer.rs +++ b/src/types/introducer.rs @@ -19,7 +19,7 @@ impl Introducer { pub fn can_introduce(&self, email: &str) -> bool { if let Some(regexset) = self.certification.regular_expressions() { if regexset.matches_everything() { - // return false, because unlimited certifications are + // return false, because unlimited certifications are // ignored. false } else { diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index 6586af8..d4ecf9a 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -12,8 +12,11 @@ use crate::types::recipient::Recipient; #[derive(PartialEq)] pub enum ProtectionPossibility { + // no recipient has a cert NoProtection, + // some recipients have a cert (but not all) Partial, + // all recipients have a cert Full } @@ -82,7 +85,8 @@ impl From<&MailContext<'_>> for ProtectionPossibility { (true, false) => ProtectionPossibility::Full, (true, true) => ProtectionPossibility::Partial, (false, true) => ProtectionPossibility::NoProtection, - (false, false) => ProtectionPossibility::NoProtection, // this should not occure + // (false, false) occurs if there are no recipients + (false, false) => ProtectionPossibility::NoProtection, } } } diff --git a/src/types/recipient.rs b/src/types/recipient.rs index 0c806b8..4288b27 100644 --- a/src/types/recipient.rs +++ b/src/types/recipient.rs @@ -37,10 +37,14 @@ impl TryFrom for Recipient<'_> { } impl Recipient<'_> { + /// check if this Recipient can encrypt. + // XXX: currently this check is naive pub fn can_encrypt(&self) -> bool { if self.certs.len() == 0 { return false; } + + // XXX: add check to see if a cert has encryption capable subkeys true } }