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.
This commit is contained in:
Malte Meiboom
2026-07-14 11:28:34 +02:00
parent 8b2f284916
commit d54cc480b4
6 changed files with 102 additions and 54 deletions
Generated
+3 -3
View File
@@ -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",
]
+4
View File
@@ -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" }
+8 -29
View File
@@ -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<Cert> {
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'.
+30 -22
View File
@@ -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<HuskContext<'_>>, config_container: HuskConfigContainer, hostname: CString) -> Status {
async fn handle_helo(_cx: &mut Context<HuskContext<'_>>, 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<HuskContext<'_>>, args: Vec<CString>) -> Status {
async fn handle_mail(cx: &mut Context<HuskContext<'_>>, config_container: HuskConfigContainer, args: Vec<CString>) -> Status {
log::debug!("MAIL: {args:?}");
let mut sender: Option<String> = 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) {
+54
View File
@@ -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<Recipient<'_>>, header: CHeader,
}
}
pub fn generate_footer(rcpts: &Vec<Recipient>) -> String {
internal_generate_footer(Utc::now().to_rfc2822(), rcpts)
}
pub(crate) fn internal_generate_footer(now: String, other_rpcts: &Vec<Recipient>) -> 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::<Vec<String>>()
.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"));
}
}
+3
View File
@@ -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]