From 2462d0ab3bbfcbb7dde918acf27c3103091fd4b0 Mon Sep 17 00:00:00 2001 From: Lewis Date: Thu, 30 Apr 2026 12:27:10 +0300 Subject: [PATCH] feat(tranquil-comms): smtp and dkim signing Lewis: May this revision serve well! --- crates/tranquil-comms/src/email/dkim.rs | 227 ++++++++++++++++++ .../test_fixtures/rsa2048-priv-pkcs1.pem | 27 +++ crates/tranquil-comms/src/email/transport.rs | 164 +++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 crates/tranquil-comms/src/email/dkim.rs create mode 100644 crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem create mode 100644 crates/tranquil-comms/src/email/transport.rs diff --git a/crates/tranquil-comms/src/email/dkim.rs b/crates/tranquil-comms/src/email/dkim.rs new file mode 100644 index 0000000..11ec47a --- /dev/null +++ b/crates/tranquil-comms/src/email/dkim.rs @@ -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 { + 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 { + 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 { + 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 { + 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}" + ); + } +} diff --git a/crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem b/crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem new file mode 100644 index 0000000..3b924f5 --- /dev/null +++ b/crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem @@ -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----- diff --git a/crates/tranquil-comms/src/email/transport.rs b/crates/tranquil-comms/src/email/transport.rs new file mode 100644 index 0000000..87e6a53 --- /dev/null +++ b/crates/tranquil-comms/src/email/transport.rs @@ -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>, + total_timeout: Duration, + }, + DirectMx { + resolver: Arc, + helo: HeloName, + command_timeout: Duration, + total_timeout: Duration, + require_tls: bool, + inflight: Arc, + }, +} + +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>>( + total: Duration, + fut: F, +) -> Result<(), SendError> { + tokio::time::timeout(total, fut) + .await + .unwrap_or(Err(SendError::Timeout)) +} + +async fn run_send( + transport: &AsyncSmtpTransport, + 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::>, |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 = + AsyncSmtpTransport::::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()), + } +}