mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-30 21:52:37 +00:00
feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
use std::fs;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
|
||||
use lettre::Message;
|
||||
use lettre::message::dkim::{
|
||||
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
|
||||
DkimSigningAlgorithm, DkimSigningKey,
|
||||
};
|
||||
use lettre::message::header::HeaderName;
|
||||
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||
use rsa::pkcs8::LineEnding;
|
||||
|
||||
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
|
||||
use crate::sender::SendError;
|
||||
|
||||
const SIGNED_HEADERS: &[&str] = &[
|
||||
"From",
|
||||
"Sender",
|
||||
"Reply-To",
|
||||
"To",
|
||||
"Cc",
|
||||
"Subject",
|
||||
"Date",
|
||||
"In-Reply-To",
|
||||
"References",
|
||||
"MIME-Version",
|
||||
"Content-Type",
|
||||
"Content-Transfer-Encoding",
|
||||
];
|
||||
|
||||
pub struct DkimSigner {
|
||||
config: LettreDkimConfig,
|
||||
}
|
||||
|
||||
impl DkimSigner {
|
||||
pub fn load(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
path: DkimKeyPath,
|
||||
) -> Result<Self, SendError> {
|
||||
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
|
||||
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
|
||||
})?;
|
||||
Self::from_pem(selector, domain, &pem)
|
||||
}
|
||||
|
||||
pub fn from_pem(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
pem: &str,
|
||||
) -> Result<Self, SendError> {
|
||||
let key = parse_key(pem)?;
|
||||
let canonicalization = DkimCanonicalization {
|
||||
header: DkimCanonicalizationType::Relaxed,
|
||||
body: DkimCanonicalizationType::Relaxed,
|
||||
};
|
||||
let headers = SIGNED_HEADERS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(HeaderName::new_from_ascii_str)
|
||||
.collect();
|
||||
let config = LettreDkimConfig::new(
|
||||
selector.into_inner(),
|
||||
domain.into_inner(),
|
||||
key,
|
||||
headers,
|
||||
canonicalization,
|
||||
);
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
pub fn sign(&self, message: &mut Message) {
|
||||
message.sign(&self.config);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DkimSigner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("DkimSigner")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let trimmed = input.trim_start();
|
||||
match trimmed {
|
||||
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
|
||||
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
|
||||
}
|
||||
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
|
||||
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
|
||||
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
|
||||
)),
|
||||
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
|
||||
SendError::DkimSign(format!(
|
||||
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let seed = BASE64_STANDARD.encode(key.to_bytes());
|
||||
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let pkcs1 = key
|
||||
.to_pkcs1_pem(LineEnding::LF)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
|
||||
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
Err(SendError::DkimSign(format!(
|
||||
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use rsa::pkcs1::DecodeRsaPrivateKey as _;
|
||||
|
||||
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
|
||||
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
|
||||
|
||||
fn ed25519_pkcs8_pem() -> String {
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn rsa_pkcs8_pem() -> String {
|
||||
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn signer(pem: &str) -> DkimSigner {
|
||||
DkimSigner::from_pem(
|
||||
DkimSelector::parse("default").unwrap(),
|
||||
EmailDomain::parse("nel.pet").unwrap(),
|
||||
pem,
|
||||
)
|
||||
.expect("key should load")
|
||||
}
|
||||
|
||||
fn signed_headers(signer: &DkimSigner) -> String {
|
||||
let from: Mailbox = "sender@nel.pet".parse().unwrap();
|
||||
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
|
||||
let mut message = Message::builder()
|
||||
.from(from)
|
||||
.to(to)
|
||||
.subject("Roundtrip")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body("Body".to_string())
|
||||
.unwrap();
|
||||
signer.sign(&mut message);
|
||||
String::from_utf8(message.formatted()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert!(matches!(
|
||||
parse_key("not a key"),
|
||||
Err(SendError::DkimSign(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_pem_type() {
|
||||
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
match parse_key(pem) {
|
||||
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
|
||||
other => panic!("expected unrecognized PEM error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_raw_seed_signs() {
|
||||
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs1_pem_signs() {
|
||||
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
fn assert_signed_with(raw: &str, algorithm: &str) {
|
||||
assert!(
|
||||
raw.contains("DKIM-Signature:"),
|
||||
"no signature header: {raw}"
|
||||
);
|
||||
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
|
||||
assert!(
|
||||
raw.contains("c=relaxed/relaxed"),
|
||||
"expected relaxed/relaxed canonicalization: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
|
||||
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
|
||||
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
|
||||
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
|
||||
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
|
||||
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
|
||||
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
|
||||
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
|
||||
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
|
||||
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
|
||||
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
|
||||
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
|
||||
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
|
||||
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
|
||||
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
|
||||
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
|
||||
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
|
||||
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
|
||||
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
|
||||
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
|
||||
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
|
||||
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
|
||||
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
|
||||
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
|
||||
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,164 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::Error as SmtpError;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::transport::smtp::extension::ClientId;
|
||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::message::recipient_domain;
|
||||
use super::mx;
|
||||
use super::types::{HeloName, MxRecord};
|
||||
use crate::sender::SendError;
|
||||
|
||||
pub enum SendMode {
|
||||
Smarthost {
|
||||
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
||||
total_timeout: Duration,
|
||||
},
|
||||
DirectMx {
|
||||
resolver: Arc<TokioAsyncResolver>,
|
||||
helo: HeloName,
|
||||
command_timeout: Duration,
|
||||
total_timeout: Duration,
|
||||
require_tls: bool,
|
||||
inflight: Arc<Semaphore>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SendMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Smarthost { total_timeout, .. } => {
|
||||
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
|
||||
}
|
||||
Self::DirectMx {
|
||||
helo, require_tls, ..
|
||||
} => write!(
|
||||
f,
|
||||
"SendMode::DirectMx({}, require_tls={require_tls})",
|
||||
helo.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
|
||||
match mode {
|
||||
SendMode::Smarthost {
|
||||
transport,
|
||||
total_timeout,
|
||||
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
||||
SendMode::DirectMx {
|
||||
resolver,
|
||||
helo,
|
||||
command_timeout,
|
||||
total_timeout,
|
||||
require_tls,
|
||||
inflight,
|
||||
} => {
|
||||
with_total_timeout(*total_timeout, async {
|
||||
let _permit =
|
||||
inflight.clone().acquire_owned().await.map_err(|_| {
|
||||
SendError::SmtpTransient("send semaphore closed".to_string())
|
||||
})?;
|
||||
send_direct(
|
||||
resolver.as_ref(),
|
||||
helo,
|
||||
*command_timeout,
|
||||
*require_tls,
|
||||
message,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
|
||||
total: Duration,
|
||||
fut: F,
|
||||
) -> Result<(), SendError> {
|
||||
tokio::time::timeout(total, fut)
|
||||
.await
|
||||
.unwrap_or(Err(SendError::Timeout))
|
||||
}
|
||||
|
||||
async fn run_send(
|
||||
transport: &AsyncSmtpTransport<Tokio1Executor>,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
transport
|
||||
.send(message)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(classify_smtp_error)
|
||||
}
|
||||
|
||||
async fn send_direct(
|
||||
resolver: &TokioAsyncResolver,
|
||||
helo: &HeloName,
|
||||
command_timeout: Duration,
|
||||
require_tls: bool,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
let domain = recipient_domain(&message)?;
|
||||
let mxs = mx::resolve(resolver, &domain).await?;
|
||||
let outcome = futures::stream::iter(mxs)
|
||||
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
match &acc {
|
||||
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
|
||||
_ => Some(
|
||||
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
|
||||
.await,
|
||||
),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
outcome.unwrap_or_else(|| {
|
||||
Err(SendError::SmtpTransient(format!(
|
||||
"no MX records returned for {}",
|
||||
domain.as_str()
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
async fn attempt_one_host(
|
||||
mx_record: MxRecord,
|
||||
helo: &HeloName,
|
||||
command_timeout: Duration,
|
||||
require_tls: bool,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
let host = mx_record.host.as_str().to_string();
|
||||
let tls_params = TlsParameters::new(host.clone())
|
||||
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
|
||||
let tls = match require_tls {
|
||||
true => Tls::Required(tls_params),
|
||||
false => Tls::Opportunistic(tls_params),
|
||||
};
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
|
||||
.port(25)
|
||||
.tls(tls)
|
||||
.hello_name(ClientId::Domain(helo.as_str().to_string()))
|
||||
.timeout(Some(command_timeout))
|
||||
.build();
|
||||
run_send(&transport, message).await
|
||||
}
|
||||
|
||||
fn classify_smtp_error(e: SmtpError) -> SendError {
|
||||
match () {
|
||||
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
|
||||
_ if e.is_timeout() => SendError::Timeout,
|
||||
_ => SendError::SmtpTransient(e.to_string()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user