Merge branch 'malte/mail_splitting' into 'main'

Mail splitting

See merge request husk-project/husk-milter!9
This commit is contained in:
Malte Meiboom
2026-07-22 09:12:32 +00:00
15 changed files with 995 additions and 627 deletions
Generated
+404 -441
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -14,17 +14,18 @@ rust-version = "1.85"
aho-corasick = "1.1.4" aho-corasick = "1.1.4"
anyhow = "1.0.100" anyhow = "1.0.100"
bytes = "1.11.1" bytes = "1.11.1"
chrono = "0.4.45"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
fastrand = "2.4.1" fastrand = "2.4.1"
indymilter = "0.3.0" indymilter = "0.3.0"
lettre = "0.11.22"
log = "0.4.30" log = "0.4.30"
log4rs = "1.3.0" log4rs = "1.3.0"
sequoia-cert-store = "0.7.3" sequoia-cert-store = "0.7.3"
sequoia-directories = "0.1.0" sequoia-directories = "0.1.0"
#sequoia-keystore = "0.7.2" sequoia-keystore = "0.7.2"
sequoia-keystore = { git = "https://gitlab.com/sequoia-pgp/sequoia-keystore", branch = "malte/softkey-refresh" }
sequoia-net = "0.30.1" sequoia-net = "0.30.1"
sequoia-openpgp = "2.3.0" sequoia-openpgp = "2.4.0"
sequoia-wot = "0.15.0" sequoia-wot = "0.15.0"
serde = "1.0.228" serde = "1.0.228"
serde_derive = "1.0.228" serde_derive = "1.0.228"
@@ -41,3 +42,11 @@ daemonix = "0.1.0"
anyhow = "1.0.100" anyhow = "1.0.100"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" 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" }
sequoia-keystore = { git = "https://gitlab.com/sequoia-pgp/sequoia-keystore", branch = "malte/softkey-refresh" }
+37 -7
View File
@@ -12,13 +12,14 @@ delete_unix_user() { # name
# send a mail and wait for delivery # send a mail and wait for delivery
send_test_mail() { # email_address send_test_mail() { # email_address
echo "send test mail to $1" >&2
USER=$(echo $1 | cut -f 1 -d '@') USER=$(echo $1 | cut -f 1 -d '@')
# create an empty mailbox file # create an empty mailbox file
touch /var/mail/$USER touch /var/mail/$USER
chown $USER:mail /var/mail/$USER chown $USER:mail /var/mail/$USER
chmod 600 /var/mail/$USER chmod 600 /var/mail/$USER
echo -e "Subject: testmail\n\ntest mail" | sendmail $1 echo -e "To: $1\nSubject: testmail\n\ntest mail" | sendmail -t
inotifywait -qq -t 3 -e close_write /var/mail/$USER inotifywait -qq -t 3 -e close_write /var/mail/$USER
} }
@@ -27,10 +28,10 @@ test_header() { # text
echo "##############################################" echo "##############################################"
echo $1 echo $1
echo "Workdir: $WORKDIR" echo "Workdir: $WORKDIR"
echo "##############################################" echo "----------------------------------------------"
} }
# Generate a key outside the cert store. The key will not be # Generate a key outside the key store. The key will not be
# authenticated. # authenticated.
gen_key() { # email gen_key() { # email
FILE=$(echo $1 | cut -f 1 -d '@') FILE=$(echo $1 | cut -f 1 -d '@')
@@ -41,31 +42,60 @@ gen_key() { # email
echo $FPR echo $FPR
} }
expect_mail() { # unix_user mode # Generate a key outside the key store. The key will not be
# authenticated. Import the certificate of the key.
gen_cert() { # email
FILE=$(echo $1 | cut -f 1 -d '@')
FPR=$(sq key generate --home none --shared-key --without-password \
--email $1 \
--output $WORKDIR/${FILE}_sk.pgp \
--rev-cert $WORKDIR/${FILE}.rev |& grep "Fingerprint:" | cut -d ':' -f 2)
sq cert import $WORKDIR/${FILE}_sk.pgp > /dev/null 2>&1
echo $FPR
}
authenticate() { # Fingerprint
sq pki link add --cert $1 --all > /dev/null 2>&1
}
expect_mail() { # unix_user mode [signature_fpr]
if [ -f /var/mail/$1 ] ; then if [ -f /var/mail/$1 ] ; then
case $2 in case $2 in
encrypted) encrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then
echo "GOOD: found expected encrypted mail" >&2
R=0 R=0
if [ "$3" != "" ] ; then
FOUND_FPR=$(sq packet dump /var/mail/$1 | grep "Issuer Fingerprint:" | cut -d ':' -f 2 | tr -d ' ')
if [ "$3" != "$FOUND_FPR" ] ; then
echo "BAD: Found wrong signature '$FOUND_FPR' - expected '$3'" >&2
R=1
else
echo "GOOD: found right signature" >&2
fi
fi
else else
echo "expected encrypted mail for $1" >&2 echo "BAD: encryped mail expected but not found" >&2
R=1 R=1
fi fi
;; ;;
unencrypted) unencrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then
echo "expected unencrypted mail for $1" >&2 echo "BAD: expected unencrypted mail for $1" >&2
R=1 R=1
else else
echo "GOOD: found expected unencrypted mail" >&2
R=0 R=0
fi fi
;; ;;
delivered) delivered)
echo "GOOD: mail delivery worked" >&2
R=0 R=0
;; ;;
esac esac
else else
echo "no mail for $1" >&2 echo "BAD: no mail for $1" >&2
R=1 R=1
fi fi
echo $R echo $R
+2 -3
View File
@@ -14,12 +14,11 @@ $HUSK_BIN introducer list --config config/config.toml
create_unix_user fred create_unix_user fred
FRED_FPR=$(sq key generate --shared-key --without-password --name "Fred" --email "fred@example.org" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) FRED_FPR=$(sq key generate --shared-key --without-password --name "Fred" --email "fred@example.org" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
sleep 1 sleep 1
sq pki link retract --cert $FRED_FPR --all sq pki link retract --cert $FRED_FPR --all > /dev/null 2>&1
sq pki vouch add --certifier $ELLEN_FPR --cert $FRED_FPR --email "fred@example.org" sq pki vouch add --certifier $ELLEN_FPR --cert $FRED_FPR --email "fred@example.org" > /dev/null 2>&1
send_test_mail fred@example.org send_test_mail fred@example.org
RESULT_CODE=$(expect_mail fred encrypted) RESULT_CODE=$(expect_mail fred encrypted)
if [ "$RESULT_CODE" == "1" ] ; then if [ "$RESULT_CODE" == "1" ] ; then
+8 -28
View File
@@ -5,7 +5,7 @@
test_header "Add locals" test_header "Add locals"
sq key generate --shared-key --without-password --email 'hubert@example.com' --output /tmp/hubert.pgp --rev-cert /tmp/hubert_rev.pgp sq key generate --shared-key --without-password --email 'hubert@example.com' --output /tmp/hubert.pgp --rev-cert /tmp/hubert_rev.pgp > /dev/null 2>&1
cd $HUSK_DIR cd $HUSK_DIR
@@ -16,47 +16,27 @@ if ! $HUSK_BIN locals list --config config/config.toml | grep -q "hubert@example
fi fi
# send a mail, husk should encrypt it # send a mail, husk should encrypt it
echo "send mail to hubert@example.com"
create_unix_user hubert create_unix_user hubert
send_test_mail hubert@example.com send_test_mail hubert@example.com
if [ -f /var/mail/hubert ] ; then R=$(expect_mail hubert encrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/hubert ; then if [ $R != 0 ] ; then
RESULT_CODE=0
else
echo "no encryption detected"
delete_unix_user hubert
exit 1
fi
# remove mail
rm /var/mail/hubert
else
echo "no mail detected"
delete_unix_user hubert delete_unix_user hubert
exit 1 exit 1
fi fi
sleep 1 sleep 1
# remove hubert as local
echo "removing hubert as local"
HUBERT_FPR=$(sq cert list --cert-email hubert@example.com 2>/dev/null | head -n 1 | cut -d ' ' -f 3) HUBERT_FPR=$(sq cert list --cert-email hubert@example.com 2>/dev/null | head -n 1 | cut -d ' ' -f 3)
$HUSK_BIN locals remove --cert $HUBERT_FPR --config config/config.toml $HUSK_BIN locals remove --cert $HUBERT_FPR --config config/config.toml
echo "send mail to hubert@example.com" rm /var/mail/hubert
send_test_mail hubert@example.com send_test_mail hubert@example.com
if [ -f /var/mail/hubert ] ; then R=$(expect_mail hubert unencrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/hubert ; then
echo "encryption detected where there should be none"
RESULT_CODE=1
else
RESULT_CODE=0
fi
else
echo "no mail detected"
RESULT_CODE=1
fi
delete_unix_user hubert delete_unix_user hubert
exit $RESULT_CODE exit $R
+11 -39
View File
@@ -5,39 +5,28 @@
test_header "Signing" test_header "Signing"
# sq key generate --shared-key --without-password --email 'signing@example.com' --output /tmp/signing.pgp --rev-cert /tmp/signing.rev SIGN_FPR_GEN=$(sq key generate --own-key --without-password --name "Signing Key" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2 | tr -d ' ')
SIGN_FPR=$(sq key generate --own-key --without-password --name "Signing Key" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
cd $HUSK_DIR cd $HUSK_DIR
$HUSK_BIN --config config/config.toml signkey set --cert $SIGN_FPR $HUSK_BIN --config config/config.toml signkey set --cert $SIGN_FPR_GEN
SIGN_FPR=$($HUSK_BIN --config config/config.toml signkey show | cut -d ':' -f 2 | tr -d ' ') SIGN_FPR=$($HUSK_BIN --config config/config.toml signkey show | cut -d ':' -f 2 | tr -d ' ')
echo "found signing key $SIGN_FPR" echo "found signing key $SIGN_FPR"
if [ "$SIGN_FPR" != "$SIGN_FPR_GEN" ] ; then
echo "setting new siging key failed, found $SIGN_FPR expected $SIGN_FPR_GEN"
exit 1
fi
create_unix_user juliette create_unix_user juliette
JULIETTE_FPR=$(sq key generate --own-key --without-password --name "Juliette" --email "juliette@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) JULIETTE_FPR=$(sq key generate --own-key --without-password --name "Juliette" --email "juliette@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
sq pki vouch add --certifier-userid "CA" --cert $JULIETTE_FPR --all > /dev/null 2>&1 # sq pki vouch add --certifier-userid "CA" --cert $JULIETTE_FPR --all > /dev/null 2>&1
# send a mail, husk should encrypt it # send a mail, husk should encrypt it
echo "send mail to juliette@example.com"
send_test_mail juliette@example.com send_test_mail juliette@example.com
if [ -f /var/mail/juliette ] ; then R=$(expect_mail juliette encrypted $SIGN_FPR)
if grep -q "BEGIN PGP MESSAGE" /var/mail/juliette ; then if [ $R != 0 ] ; then
FOUND_FPR=$(sq packet dump /var/mail/juliette | grep "Issuer Fingerprint:" | cut -d ':' -f 2 | tr -d ' ')
if [ "$SIGN_FPR" != "$FOUND_FPR" ] ; then
echo "Found wrong fingerprint '$FOUND_FPR' - expected '$SIGN_FPR'"
exit 1
fi
else
echo "no encryption detected"
exit 1
fi
else
echo "no mail detected"
exit 1 exit 1
fi fi
@@ -50,23 +39,6 @@ echo "new signing key $SIGN_FPR"
# remove old mail # remove old mail
rm /var/mail/juliette rm /var/mail/juliette
echo "send mail to juliette@example.com"
send_test_mail juliette@example.com send_test_mail juliette@example.com
if [ -f /var/mail/juliette ] ; then exit $(expect_mail juliette encrypted $SIGN_FPR)
if grep -q "BEGIN PGP MESSAGE" /var/mail/juliette ; then
FOUND_FPR=$(sq packet dump /var/mail/juliette | grep "Issuer Fingerprint:" | cut -d ':' -f 2 | tr -d ' ')
if [ "$SIGN_FPR" != "$FOUND_FPR" ] ; then
echo "Found wrong fingerprint '$FOUND_FPR' - expected '$SIGN_FPR'"
exit 1
fi
else
echo "no encryption detected"
exit 1
fi
else
echo "no mail detected"
exit 1
fi
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/bash
# source some functions
. common.sh
test_header "Mail split"
create_unix_user karl
KARL_FPR=$(gen_cert karl@example.com)
authenticate $KARL_FPR
create_unix_user laura
echo -e "To: karl@example.com, Laura <laura@example.com>\nSubject: Testmail\n\nTestmail." | sendmail -t
sleep 1
R=1
if [ $(expect_mail karl encrypted) == 0 ] ; then
if [ $(expect_mail laura unencrypted) == 0 ] ; then
R=0
fi
fi
exit $R
+9 -30
View File
@@ -23,7 +23,6 @@ use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse};
use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::policy::StandardPolicy;
use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter}; use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter};
use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::{Fingerprint, Cert, Packet};
use sequoia_openpgp::cert::raw::RawCertParser;
use sequoia_openpgp::types::SignatureType; use sequoia_openpgp::types::SignatureType;
use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate}; use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate};
use sequoia_cert_store::store::MergePublicCollectStats; 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 /// Returns the local trust root from the cert_store. If the trust root
/// cannot be found, it returns an error. /// cannot be found, it returns an error.
pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result<Cert> { pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result<Cert> {
if let Some((root, created)) = cert_store.certd().and_then(|certd| {
let root = cert_store.certd() certd.trust_root().ok() }) {
.and_then(|certd| { if created {
match certd.certd().get(sequoia_cert_store::store::openpgp_cert_d::TRUST_ROOT) { log::info!("Local trust root not found - created one");
Ok(Some((_tag, bytes))) => Some(bytes), }
Ok(None) => None, Ok(root.to_cert()?.clone())
Err(_) => None } else {
} Err(HuskError::NoLocalTrustRoot.into())
})
.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())
} }
} }
/// Returns all local certificates with an authenticated binding to user id 'email'. /// Returns all local certificates with an authenticated binding to user id 'email'.
@@ -255,7 +234,7 @@ pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
for introducer in &context.introducers { for introducer in &context.introducers {
log::debug!("consider introducer {:?}", log::debug!("consider introducer {:?}",
introducer.cert.userids().map(|u| u.userid()).collect::<Vec<_>>()); introducer.cert.self_signed_userids().map(|u| u.userid()).collect::<Vec<_>>());
if introducer.can_introduce(format!("<{}>", email).as_str()) { if introducer.can_introduce(format!("<{}>", email).as_str()) {
log::debug!(" introducer regex matches!"); log::debug!(" introducer regex matches!");
match lookup_certificates(context, email).await { match lookup_certificates(context, email).await {
+158 -60
View File
@@ -1,7 +1,7 @@
//! the actual daemon //! the actual daemon
use anyhow; use anyhow;
use bytes::Bytes; use bytes::{Bytes, BytesMut};
use std::ffi::CString; use std::ffi::CString;
use tokio::signal; use tokio::signal;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -18,6 +18,7 @@ use indymilter::{
ContextActions, ContextActions,
}; };
use crate::types::defaults::HELO_REINJECT_ID;
use crate::types::husk_context::HuskContext; use crate::types::husk_context::HuskContext;
use crate::config::{HuskConfig, HuskConfigContainer}; use crate::config::{HuskConfig, HuskConfigContainer};
use crate::mail; use crate::mail;
@@ -39,8 +40,8 @@ impl Daemon {
let callbacks = Callbacks::new() let callbacks = Callbacks::new()
.on_negotiate(|cx, actions, opts| Box::pin(Self::handle_negotiate(cx, actions, opts))) .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_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_helo(|cx, hostname| Box::pin(Self::handle_helo(cx, hostname)))
.on_mail(|cx, args| Box::pin(Self::handle_mail(cx, args))) .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_rcpt(|cx, args| Box::pin(Self::handle_rcpt(cx, args)))
.on_data(|cx| Box::pin(Self::handle_data(cx))) .on_data(|cx| Box::pin(Self::handle_data(cx)))
.on_header(|cx, name, value| Box::pin(Self::handle_header(cx, name, value))) .on_header(|cx, name, value| Box::pin(Self::handle_header(cx, name, value)))
@@ -80,26 +81,19 @@ impl Daemon {
Status::Continue 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:?}"); log::debug!("HELO: hostname: {hostname:?}");
let config: HuskConfig = config_container.into(); if hostname.to_str() == Ok(HELO_REINJECT_ID) {
// Reinjected mail detected - this mail is unencrypted because
// setup Context // all recipients lack a valid certificate. Accept mail.
match HuskContext::new(&config) { Status::Accept
Ok(context) => { } else {
log::debug!("found introducers: {:?}", context.introducers); Status::Continue
cx.data = Some(context);
Status::Continue
},
Err(e) => {
log::error!("{}", e);
Status::Tempfail
}
} }
} }
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:?}"); log::debug!("MAIL: {args:?}");
let mut sender: Option<String> = None; let mut sender: Option<String> = None;
@@ -117,7 +111,20 @@ impl Daemon {
context.set_sender(sender.unwrap()); context.set_sender(sender.unwrap());
Status::Continue Status::Continue
} else { } 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
}
}
} }
} }
@@ -202,58 +209,149 @@ impl Daemon {
return Status::Accept; return Status::Accept;
} }
// possible protection let from = match context.mail.sender.clone() {
// none -> accept Some(f) => f,
// partial -> accept (later: split) None => {
// full -> encrypt 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 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 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); // adjust headers
// This is mostly Content-Type related.
// if an error occures the body is not exchanged for line in outer_mail.get_header().iter_lines() {
match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) { // XXX: handle errors
Ok(encrypted) => { if let Ok(key) = line.key_as_cstring() {
// replace the body of the mail with an encrypted, if let Ok(value) = line.value_as_cstring() {
// mime encoded version. let _ = cx.actions.change_header(
let outer_mail = MailContext::generate_outer_mail(encrypted); key, 1,
if cx.actions.replace_body( Some(value)
outer_mail ).await;
.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;
} }
} }
// 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 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,
&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, encrypted_body.into());
// 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 Status::Continue
} }
+150
View File
@@ -1,8 +1,24 @@
//! mail functions //! mail functions
use anyhow::Result;
use bytes::Bytes;
use bytes::BytesMut;
use std::ffi::CString; use std::ffi::CString;
use sequoia_openpgp::packet::UserID; 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 chrono::Utc;
use crate::types::cheader::CHeader;
use crate::types::defaults;
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<UserID> { pub fn to_userid(rcpt: &CString) -> Option<UserID> {
match rcpt.to_str() { match rcpt.to_str() {
Ok(s) => Some(UserID::from(s)), Ok(s) => Some(UserID::from(s)),
@@ -10,6 +26,7 @@ pub fn to_userid(rcpt: &CString) -> Option<UserID> {
} }
} }
/// Get the email from the string representation of a user id.
pub fn to_email(rcpt: &CString) -> Option<String> { pub fn to_email(rcpt: &CString) -> Option<String> {
if let Some(u) = to_userid(rcpt) { if let Some(u) = to_userid(rcpt) {
if let Ok(Some(e)) = u.email() { Some(e.to_string()) } if let Ok(Some(e)) = u.email() { Some(e.to_string()) }
@@ -32,3 +49,136 @@ pub fn is_email(email: &str) -> bool {
pub fn is_domain(domain: &str) -> bool { pub fn is_domain(domain: &str) -> bool {
is_email(format!("test@{}", domain).as_str()) is_email(format!("test@{}", domain).as_str())
} }
pub async fn smtp_inject(from: String, to: &Vec<Recipient<'_>>, header: CHeader, body: &Bytes) -> Result<()> {
let mut joinset: JoinSet<Result<()>> = 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<Address> = Vec::new();
for rcpt in to {
let rcpt = rcpt.email.clone();
rcpts.push(rcpt.try_into()?);
}
let my_id = ClientId::Domain(defaults::HELO_REINJECT_ID.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())
},
}
}
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"));
}
}
+74 -4
View File
@@ -5,6 +5,11 @@
use std::ffi::CString; use std::ffi::CString;
use std::fmt; use std::fmt;
use anyhow::Result;
use crate::types::recipient::Recipient;
#[derive(Debug, PartialEq, Clone)]
pub struct CHeader { pub struct CHeader {
lines: Vec<(CString, CString)> lines: Vec<(CString, CString)>
} }
@@ -52,6 +57,47 @@ impl CHeader {
pub fn iter_lines(&self) -> std::slice::Iter<'_, (CString, CString)> { pub fn iter_lines(&self) -> std::slice::Iter<'_, (CString, CString)> {
self.lines.iter() 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<Recipient>) -> Result<CString> {
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::<Vec<&str>>();
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<Recipient>) -> 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 { impl fmt::Display for CHeader {
@@ -75,10 +121,12 @@ impl fmt::Display for CHeader {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use std::ffi::CString; use std::ffi::CString;
use crate::types::recipient::Recipient;
use super::CHeader; use super::CHeader;
#[test] #[test]
pub fn basic_functionality() { pub fn basic_functionality() {
let cheader = CHeader::new() let cheader = CHeader::new()
.add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) .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()); assert_eq!("Content-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n", cheader.to_string());
} }
#[test] #[test]
pub fn find() { pub fn find() {
let cheader = CHeader::new() let cheader = CHeader::new()
.add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap())
@@ -103,7 +151,7 @@ pub mod tests {
assert_eq!("text/plain", value); assert_eq!("text/plain", value);
} }
#[test] #[test]
pub fn find_none() { pub fn find_none() {
let cheader = CHeader::new() let cheader = CHeader::new()
.add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap())
@@ -114,7 +162,7 @@ pub mod tests {
assert!(line.is_none()); assert!(line.is_none());
} }
#[test] #[test]
pub fn find_with_default() { pub fn find_with_default() {
let cheader = CHeader::new() let cheader = CHeader::new()
.add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap()) .add(CString::new("Content-Type").unwrap(), CString::new("text/plain").unwrap())
@@ -124,5 +172,27 @@ pub mod tests {
assert_eq!(value, "baaz"); assert_eq!(value, "baaz");
} }
#[test]
pub fn remove_recipients() {
let header = CHeader::new()
.add(CString::new("To").unwrap(), CString::new("Alice <alice@example.com>, Bob <bob@other.org>").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 <alice@example.com>").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 <alice@example.com>, Bob <bob@other.org>").unwrap());
assert_eq!(modified_header, expected_header);
}
} }
+6
View File
@@ -8,6 +8,12 @@ pub const SUBJECT_REPLACEMENT: &str = "...";
pub const PID_FILE_LOCATION: &str = "/run/husk/pid"; 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";
/// This ID is used when reinjecting an unencrypted mail into the MTA.
pub const HELO_REINJECT_ID: &str = "husk.reinject";
/// Default logging configuration. /// Default logging configuration.
pub const CONSOLE_LOGGING: &str = " pub const CONSOLE_LOGGING: &str = "
[appenders.console] [appenders.console]
+2
View File
@@ -24,4 +24,6 @@ pub enum HuskError {
InvalidDomainName(String), InvalidDomainName(String),
#[error("Certificate has no user ids - {0}")] #[error("Certificate has no user ids - {0}")]
NoUserIDs(String), NoUserIDs(String),
#[error("Failing to send mail: {0}")]
SmtpInjectionFailure(String),
} }
+1 -1
View File
@@ -110,7 +110,7 @@ use std::fmt::{Display, Formatter};
impl Display for Introducer { impl Display for Introducer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
// XXX: This is unsafe as ANSI control chars are not handled. // XXX: This is unsafe as ANSI control chars are not handled.
let uids = self.cert.userids() let uids = self.cert.self_signed_userids()
.map(|u| String::from_utf8_lossy(u.userid().value()).to_string()) .map(|u| String::from_utf8_lossy(u.userid().value()).to_string())
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "); .join(", ");
+97 -11
View File
@@ -15,17 +15,15 @@ use crate::types::mime::header::Header;
use crate::types::mime::part::Part; use crate::types::mime::part::Part;
use crate::types::mime::multi_part::MultiPart; use crate::types::mime::multi_part::MultiPart;
#[derive(PartialEq)] pub enum ProtectionPossibility<'mc> {
pub enum ProtectionPossibility {
// no recipient has a cert // no recipient has a cert
NoProtection, NoProtection,
// some recipients have a cert (but not all) // some recipients have a cert (but not all)
Partial, Partial(Vec<Recipient<'mc>>, Vec<Recipient<'mc>>),
// all recipients have a cert // all recipients have a cert
Full Full
} }
pub struct MailContext<'mc> { pub struct MailContext<'mc> {
pub sender: Option<String>, pub sender: Option<String>,
pub recipients: Vec<Recipient<'mc>>, pub recipients: Vec<Recipient<'mc>>,
@@ -84,8 +82,8 @@ impl<'mc> MailContext<'mc> {
buffer.into() buffer.into()
} }
pub fn generate_inner_mail(&self, body: Bytes) -> MultiPart<'mc> { pub fn generate_inner_mail(header: &CHeader, body: Bytes) -> MultiPart<'mc> {
let header = &self.header; //let header = &self.header;
let inner = Part::with_body(body) let inner = Part::with_body(body)
.set_header(Header::new() .set_header(Header::new()
.add( .add(
@@ -158,22 +156,27 @@ impl<'mc> Default for MailContext<'mc> {
} }
} }
impl From<&MailContext<'_>> for ProtectionPossibility { impl<'mc> From<&MailContext<'mc>> for ProtectionPossibility<'mc> {
fn from(mail_context: &MailContext<'_>) -> ProtectionPossibility { fn from(mail_context: &MailContext<'mc>) -> ProtectionPossibility<'mc> {
let mut can_encrypt = false; let mut can_encrypt = false;
let mut cannot_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() { for rcpt in mail_context.recipients.iter() {
if rcpt.can_encrypt() { if rcpt.can_encrypt() {
can_encrypt = true; can_encrypt = true;
encrypted_rcpt.push(rcpt.clone());
} else { } else {
cannot_encrypt = true; cannot_encrypt = true;
unencrypted_rcpt.push(rcpt.clone());
} }
} }
match (can_encrypt, cannot_encrypt) { match (can_encrypt, cannot_encrypt) {
(true, false) => ProtectionPossibility::Full, (true, false) => ProtectionPossibility::Full,
(true, true) => ProtectionPossibility::Partial, (true, true) => ProtectionPossibility::Partial(encrypted_rcpt, unencrypted_rcpt),
(false, true) => ProtectionPossibility::NoProtection, (false, true) => ProtectionPossibility::NoProtection,
// (false, false) occurs if there are no recipients // (false, false) occurs if there are no recipients
(false, false) => ProtectionPossibility::NoProtection, (false, false) => ProtectionPossibility::NoProtection,
@@ -185,8 +188,17 @@ impl From<&MailContext<'_>> for ProtectionPossibility {
mod tests { mod tests {
use std::ffi::CString; 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::cheader::CHeader;
use crate::types::mail_context::MailContext; use crate::types::mail_context::MailContext;
use crate::types::recipient::Recipient;
use super::ProtectionPossibility;
#[test] #[test]
fn test_inner_mail() { fn test_inner_mail() {
@@ -202,8 +214,7 @@ mod tests {
let orig_body = "This is the body".as_bytes().to_vec(); let orig_body = "This is the body".as_bytes().to_vec();
let context = MailContext::from_header(orig_header); let inner_mail = MailContext::generate_inner_mail(&orig_header, orig_body.into());
let inner_mail = context.generate_inner_mail(orig_body.into());
let intended_result = [ let intended_result = [
format!("Content-Type: multipart/mixed; boundary=\"{}\"; protected-headers=\"v1\"", inner_mail.get_boundary()), 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()); 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(())
}
} }