Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
221
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tranquil-comms): smtp and dkim signing #8

open opened by oyster.cafe targeting main from feat/inline-emailing
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mkuoecysa322
+418
Diff #3
+227
crates/tranquil-comms/src/email/dkim.rs
··· 1 + use std::fs; 2 + 3 + use base64::Engine; 4 + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; 5 + use ed25519_dalek::pkcs8::DecodePrivateKey as _; 6 + use lettre::Message; 7 + use lettre::message::dkim::{ 8 + DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig, 9 + DkimSigningAlgorithm, DkimSigningKey, 10 + }; 11 + use lettre::message::header::HeaderName; 12 + use rsa::pkcs1::EncodeRsaPrivateKey; 13 + use rsa::pkcs8::LineEnding; 14 + 15 + use super::types::{DkimKeyPath, DkimSelector, EmailDomain}; 16 + use crate::sender::SendError; 17 + 18 + const SIGNED_HEADERS: &[&str] = &[ 19 + "From", 20 + "Sender", 21 + "Reply-To", 22 + "To", 23 + "Cc", 24 + "Subject", 25 + "Date", 26 + "In-Reply-To", 27 + "References", 28 + "MIME-Version", 29 + "Content-Type", 30 + "Content-Transfer-Encoding", 31 + ]; 32 + 33 + pub struct DkimSigner { 34 + config: LettreDkimConfig, 35 + } 36 + 37 + impl DkimSigner { 38 + pub fn load( 39 + selector: DkimSelector, 40 + domain: EmailDomain, 41 + path: DkimKeyPath, 42 + ) -> Result<Self, SendError> { 43 + let pem = fs::read_to_string(path.as_path()).map_err(|e| { 44 + SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display())) 45 + })?; 46 + Self::from_pem(selector, domain, &pem) 47 + } 48 + 49 + pub fn from_pem( 50 + selector: DkimSelector, 51 + domain: EmailDomain, 52 + pem: &str, 53 + ) -> Result<Self, SendError> { 54 + let key = parse_key(pem)?; 55 + let canonicalization = DkimCanonicalization { 56 + header: DkimCanonicalizationType::Relaxed, 57 + body: DkimCanonicalizationType::Relaxed, 58 + }; 59 + let headers = SIGNED_HEADERS 60 + .iter() 61 + .copied() 62 + .map(HeaderName::new_from_ascii_str) 63 + .collect(); 64 + let config = LettreDkimConfig::new( 65 + selector.into_inner(), 66 + domain.into_inner(), 67 + key, 68 + headers, 69 + canonicalization, 70 + ); 71 + Ok(Self { config }) 72 + } 73 + 74 + pub fn sign(&self, message: &mut Message) { 75 + message.sign(&self.config); 76 + } 77 + } 78 + 79 + impl std::fmt::Debug for DkimSigner { 80 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 81 + f.write_str("DkimSigner") 82 + } 83 + } 84 + 85 + fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> { 86 + let trimmed = input.trim_start(); 87 + match trimmed { 88 + s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => { 89 + DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa) 90 + .map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}"))) 91 + } 92 + s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input), 93 + s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign( 94 + "unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(), 95 + )), 96 + _ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| { 97 + SendError::DkimSign(format!( 98 + "expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}" 99 + )) 100 + }), 101 + } 102 + } 103 + 104 + fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> { 105 + let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) { 106 + Ok(key) => { 107 + let seed = BASE64_STANDARD.encode(key.to_bytes()); 108 + return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519) 109 + .map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}"))); 110 + } 111 + Err(e) => e, 112 + }; 113 + 114 + let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) { 115 + Ok(key) => { 116 + let pkcs1 = key 117 + .to_pkcs1_pem(LineEnding::LF) 118 + .map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?; 119 + return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa) 120 + .map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}"))); 121 + } 122 + Err(e) => e, 123 + }; 124 + 125 + Err(SendError::DkimSign(format!( 126 + "PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}" 127 + ))) 128 + } 129 + 130 + #[cfg(test)] 131 + mod tests { 132 + use super::*; 133 + use ed25519_dalek::pkcs8::EncodePrivateKey as _; 134 + use lettre::message::Mailbox; 135 + use lettre::message::header::ContentType; 136 + use rsa::pkcs1::DecodeRsaPrivateKey as _; 137 + 138 + const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI="; 139 + 140 + const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem"); 141 + 142 + fn ed25519_pkcs8_pem() -> String { 143 + let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); 144 + key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string() 145 + } 146 + 147 + fn rsa_pkcs8_pem() -> String { 148 + let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap(); 149 + key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string() 150 + } 151 + 152 + fn signer(pem: &str) -> DkimSigner { 153 + DkimSigner::from_pem( 154 + DkimSelector::parse("default").unwrap(), 155 + EmailDomain::parse("nel.pet").unwrap(), 156 + pem, 157 + ) 158 + .expect("key should load") 159 + } 160 + 161 + fn signed_headers(signer: &DkimSigner) -> String { 162 + let from: Mailbox = "sender@nel.pet".parse().unwrap(); 163 + let to: Mailbox = "recipient@nel.pet".parse().unwrap(); 164 + let mut message = Message::builder() 165 + .from(from) 166 + .to(to) 167 + .subject("Roundtrip") 168 + .header(ContentType::TEXT_PLAIN) 169 + .body("Body".to_string()) 170 + .unwrap(); 171 + signer.sign(&mut message); 172 + String::from_utf8(message.formatted()).unwrap() 173 + } 174 + 175 + #[test] 176 + fn rejects_garbage() { 177 + assert!(matches!( 178 + parse_key("not a key"), 179 + Err(SendError::DkimSign(_)) 180 + )); 181 + } 182 + 183 + #[test] 184 + fn rejects_unknown_pem_type() { 185 + let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n"; 186 + match parse_key(pem) { 187 + Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"), 188 + other => panic!("expected unrecognized PEM error, got {other:?}"), 189 + } 190 + } 191 + 192 + #[test] 193 + fn ed25519_raw_seed_signs() { 194 + let raw = signed_headers(&signer(ED25519_RAW_SEED_B64)); 195 + assert_signed_with(&raw, "a=ed25519-sha256"); 196 + } 197 + 198 + #[test] 199 + fn ed25519_pkcs8_pem_signs() { 200 + let raw = signed_headers(&signer(&ed25519_pkcs8_pem())); 201 + assert_signed_with(&raw, "a=ed25519-sha256"); 202 + } 203 + 204 + #[test] 205 + fn rsa_pkcs1_pem_signs() { 206 + let raw = signed_headers(&signer(RSA_PKCS1_PEM)); 207 + assert_signed_with(&raw, "a=rsa-sha256"); 208 + } 209 + 210 + #[test] 211 + fn rsa_pkcs8_pem_signs() { 212 + let raw = signed_headers(&signer(&rsa_pkcs8_pem())); 213 + assert_signed_with(&raw, "a=rsa-sha256"); 214 + } 215 + 216 + fn assert_signed_with(raw: &str, algorithm: &str) { 217 + assert!( 218 + raw.contains("DKIM-Signature:"), 219 + "no signature header: {raw}" 220 + ); 221 + assert!(raw.contains(algorithm), "missing {algorithm}: {raw}"); 222 + assert!( 223 + raw.contains("c=relaxed/relaxed"), 224 + "expected relaxed/relaxed canonicalization: {raw}" 225 + ); 226 + } 227 + }
+27
crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem
··· 1 + -----BEGIN RSA PRIVATE KEY----- 2 + MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p 3 + 78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC 4 + 38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6 5 + 7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/ 6 + vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+ 7 + CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh 8 + XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A 9 + ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e 10 + 0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t 11 + 4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg 12 + VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe 13 + EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm 14 + FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W 15 + 2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU 16 + dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN 17 + 11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq 18 + +w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF 19 + wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/ 20 + 4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK 21 + 8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg 22 + C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq 23 + vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR 24 + GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH 25 + kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y 26 + hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3 27 + -----END RSA PRIVATE KEY-----
+164
crates/tranquil-comms/src/email/transport.rs
··· 1 + use std::sync::Arc; 2 + use std::time::Duration; 3 + 4 + use futures::StreamExt; 5 + use hickory_resolver::TokioAsyncResolver; 6 + use lettre::transport::smtp::AsyncSmtpTransport; 7 + use lettre::transport::smtp::Error as SmtpError; 8 + use lettre::transport::smtp::client::{Tls, TlsParameters}; 9 + use lettre::transport::smtp::extension::ClientId; 10 + use lettre::{AsyncTransport, Message, Tokio1Executor}; 11 + use tokio::sync::Semaphore; 12 + 13 + use super::message::recipient_domain; 14 + use super::mx; 15 + use super::types::{HeloName, MxRecord}; 16 + use crate::sender::SendError; 17 + 18 + pub enum SendMode { 19 + Smarthost { 20 + transport: Box<AsyncSmtpTransport<Tokio1Executor>>, 21 + total_timeout: Duration, 22 + }, 23 + DirectMx { 24 + resolver: Arc<TokioAsyncResolver>, 25 + helo: HeloName, 26 + command_timeout: Duration, 27 + total_timeout: Duration, 28 + require_tls: bool, 29 + inflight: Arc<Semaphore>, 30 + }, 31 + } 32 + 33 + impl std::fmt::Debug for SendMode { 34 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 35 + match self { 36 + Self::Smarthost { total_timeout, .. } => { 37 + write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})") 38 + } 39 + Self::DirectMx { 40 + helo, require_tls, .. 41 + } => write!( 42 + f, 43 + "SendMode::DirectMx({}, require_tls={require_tls})", 44 + helo.as_str() 45 + ), 46 + } 47 + } 48 + } 49 + 50 + pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> { 51 + match mode { 52 + SendMode::Smarthost { 53 + transport, 54 + total_timeout, 55 + } => with_total_timeout(*total_timeout, run_send(transport, message)).await, 56 + SendMode::DirectMx { 57 + resolver, 58 + helo, 59 + command_timeout, 60 + total_timeout, 61 + require_tls, 62 + inflight, 63 + } => { 64 + with_total_timeout(*total_timeout, async { 65 + let _permit = 66 + inflight.clone().acquire_owned().await.map_err(|_| { 67 + SendError::SmtpTransient("send semaphore closed".to_string()) 68 + })?; 69 + send_direct( 70 + resolver.as_ref(), 71 + helo, 72 + *command_timeout, 73 + *require_tls, 74 + message, 75 + ) 76 + .await 77 + }) 78 + .await 79 + } 80 + } 81 + } 82 + 83 + async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>( 84 + total: Duration, 85 + fut: F, 86 + ) -> Result<(), SendError> { 87 + tokio::time::timeout(total, fut) 88 + .await 89 + .unwrap_or(Err(SendError::Timeout)) 90 + } 91 + 92 + async fn run_send( 93 + transport: &AsyncSmtpTransport<Tokio1Executor>, 94 + message: Message, 95 + ) -> Result<(), SendError> { 96 + transport 97 + .send(message) 98 + .await 99 + .map(|_| ()) 100 + .map_err(classify_smtp_error) 101 + } 102 + 103 + async fn send_direct( 104 + resolver: &TokioAsyncResolver, 105 + helo: &HeloName, 106 + command_timeout: Duration, 107 + require_tls: bool, 108 + message: Message, 109 + ) -> Result<(), SendError> { 110 + let domain = recipient_domain(&message)?; 111 + let mxs = mx::resolve(resolver, &domain).await?; 112 + let outcome = futures::stream::iter(mxs) 113 + .fold(None::<Result<(), SendError>>, |acc, mx_record| { 114 + let message = message.clone(); 115 + async move { 116 + match &acc { 117 + Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc, 118 + _ => Some( 119 + attempt_one_host(mx_record, helo, command_timeout, require_tls, message) 120 + .await, 121 + ), 122 + } 123 + } 124 + }) 125 + .await; 126 + outcome.unwrap_or_else(|| { 127 + Err(SendError::SmtpTransient(format!( 128 + "no MX records returned for {}", 129 + domain.as_str() 130 + ))) 131 + }) 132 + } 133 + 134 + async fn attempt_one_host( 135 + mx_record: MxRecord, 136 + helo: &HeloName, 137 + command_timeout: Duration, 138 + require_tls: bool, 139 + message: Message, 140 + ) -> Result<(), SendError> { 141 + let host = mx_record.host.as_str().to_string(); 142 + let tls_params = TlsParameters::new(host.clone()) 143 + .map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?; 144 + let tls = match require_tls { 145 + true => Tls::Required(tls_params), 146 + false => Tls::Opportunistic(tls_params), 147 + }; 148 + let transport: AsyncSmtpTransport<Tokio1Executor> = 149 + AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host) 150 + .port(25) 151 + .tls(tls) 152 + .hello_name(ClientId::Domain(helo.as_str().to_string())) 153 + .timeout(Some(command_timeout)) 154 + .build(); 155 + run_send(&transport, message).await 156 + } 157 + 158 + fn classify_smtp_error(e: SmtpError) -> SendError { 159 + match () { 160 + _ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()), 161 + _ if e.is_timeout() => SendError::Timeout, 162 + _ => SendError::SmtpTransient(e.to_string()), 163 + } 164 + }

History

4 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat(tranquil-comms): smtp and dkim signing
merge conflicts detected
expand
  • .config/nextest.toml:68
  • Cargo.lock:9
  • Cargo.toml:26
  • crates/tranquil-comms/Cargo.toml:10
  • crates/tranquil-config/src/lib.rs:5
  • example.toml:373
expand 0 comments
1 commit
expand
feat(tranquil-comms): smtp and dkim signing
expand 0 comments
1 commit
expand
feat(tranquil-comms): smtp and dkim signing
expand 0 comments
1 commit
expand
feat(tranquil-comms): smtp and dkim signing
expand 0 comments