diff --git a/changelog.md b/changelog.md index 77b419c..61f5333 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,20 @@ # Changelog +## 2026-02-11 - 5.1.0 - feat(mailer-smtp) +add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements + +- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult). +- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability. +- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths. +- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance. +- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API. +- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included. +- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries. +- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust. +- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates. +- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult. +- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows. + ## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail) remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7633882..f5eb88f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "mailer-core", "mailer-security", "mailer-smtp", + "rustls", "serde", "serde_json", "tokio", @@ -1025,15 +1026,18 @@ dependencies = [ "base64", "dashmap", "hickory-resolver 0.25.2", + "hmac", "mailer-core", "mailer-security", "mailparse", + "pbkdf2", "regex", "rustls", "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tokio-rustls", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d07b146..7734015 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -29,3 +29,6 @@ uuid = { version = "1", features = ["v4"] } rustls-pki-types = "1" psl = "2" clap = { version = "4", features = ["derive"] } +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = { version = "0.12", default-features = false } diff --git a/rust/crates/mailer-bin/Cargo.toml b/rust/crates/mailer-bin/Cargo.toml index 6b7ea8d..1730684 100644 --- a/rust/crates/mailer-bin/Cargo.toml +++ b/rust/crates/mailer-bin/Cargo.toml @@ -21,3 +21,4 @@ hickory-resolver.workspace = true dashmap.workspace = true base64.workspace = true uuid.workspace = true +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } diff --git a/rust/crates/mailer-bin/src/main.rs b/rust/crates/mailer-bin/src/main.rs index 5fb4132..5d1ad70 100644 --- a/rust/crates/mailer-bin/src/main.rs +++ b/rust/crates/mailer-bin/src/main.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tokio::sync::oneshot; use mailer_smtp::connection::{ - AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, + AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult, }; /// mailer-bin: Rust-powered email security tools @@ -114,10 +114,11 @@ struct IpcEvent { // --- Pending callbacks for correlation-ID based reverse calls --- -/// Stores oneshot senders for pending email processing and auth callbacks. +/// Stores oneshot senders for pending email processing, auth, and SCRAM callbacks. struct PendingCallbacks { email: DashMap>, auth: DashMap>, + scram: DashMap>, } impl PendingCallbacks { @@ -125,6 +126,7 @@ impl PendingCallbacks { Self { email: DashMap::new(), auth: DashMap::new(), + scram: DashMap::new(), } } } @@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks { self.auth.insert(correlation_id.to_string(), tx); rx } + + fn register_scram_callback( + &self, + correlation_id: &str, + ) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.scram.insert(correlation_id.to_string(), tx); + rx + } } fn main() { + // Install the ring CryptoProvider for rustls TLS operations (STARTTLS, implicit TLS). + // This must happen before any TLS connection is attempted. + let _ = rustls::crypto::ring::default_provider().install_default(); + let cli = Cli::parse(); if cli.management { @@ -494,6 +509,22 @@ fn handle_smtp_event(event: ConnectionEvent) { }), ); } + ConnectionEvent::ScramCredentialRequest { + correlation_id, + session_id, + username, + remote_addr, + } => { + emit_event( + "scramCredentialRequest", + serde_json::json!({ + "correlationId": correlation_id, + "sessionId": session_id, + "username": username, + "remoteAddr": remote_addr, + }), + ); + } } } @@ -642,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip .get("privateKey") .and_then(|v| v.as_str()) .unwrap_or(""); + let key_type = req + .params + .get("keyType") + .and_then(|v| v.as_str()) + .unwrap_or("rsa"); - match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) { + match mailer_security::sign_dkim_auto(raw_message.as_bytes(), domain, selector, private_key, key_type) { Ok(header) => IpcResponse { id: req.id.clone(), success: true, @@ -825,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip handle_auth_result(req, state) } + "scramCredentialResult" => { + handle_scram_credential_result(req, state) + } + "configureRateLimits" => { // Rate limit configuration is set at startSmtpServer time. // This command allows runtime updates, but for now we acknowledge it. @@ -1010,6 +1050,56 @@ fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse } } +/// Handle scramCredentialResult IPC command — resolves a pending SCRAM credential callback. +fn handle_scram_credential_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse { + use base64::Engine; + use base64::engine::general_purpose::STANDARD as BASE64; + + let correlation_id = req + .params + .get("correlationId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let found = req.params.get("found").and_then(|v| v.as_bool()).unwrap_or(false); + + let result = ScramCredentialResult { + found, + salt: req.params.get("salt") + .and_then(|v| v.as_str()) + .and_then(|s| BASE64.decode(s.as_bytes()).ok()), + iterations: req.params.get("iterations") + .and_then(|v| v.as_u64()) + .map(|n| n as u32), + stored_key: req.params.get("storedKey") + .and_then(|v| v.as_str()) + .and_then(|s| BASE64.decode(s.as_bytes()).ok()), + server_key: req.params.get("serverKey") + .and_then(|v| v.as_str()) + .and_then(|s| BASE64.decode(s.as_bytes()).ok()), + }; + + if let Some((_, tx)) = state.callbacks.scram.remove(correlation_id) { + let _ = tx.send(result); + IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::json!({"resolved": true})), + error: None, + } + } else { + IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!( + "No pending SCRAM credential callback for correlationId: {}", + correlation_id + )), + } + } +} + /// Parse SmtpServerConfig from IPC params JSON. fn parse_smtp_config( params: &serde_json::Value, @@ -1075,6 +1165,27 @@ fn parse_smtp_config( config.processing_timeout_secs = timeout; } + // Parse additional TLS certs for SNI + if let Some(certs_arr) = params.get("additionalTlsCerts").and_then(|v| v.as_array()) { + for cert_val in certs_arr { + if let (Some(domains_arr), Some(cert_pem), Some(key_pem)) = ( + cert_val.get("domains").and_then(|v| v.as_array()), + cert_val.get("certPem").and_then(|v| v.as_str()), + cert_val.get("keyPem").and_then(|v| v.as_str()), + ) { + let domains: Vec = domains_arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + config.additional_tls_certs.push(mailer_smtp::config::TlsDomainCert { + domains, + cert_pem: cert_pem.to_string(), + key_pem: key_pem.to_string(), + }); + } + } + } + Ok(config) } @@ -1187,11 +1298,12 @@ async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResp // Optional DKIM signing if let Some(dkim_val) = req.params.get("dkim") { if let Ok(dkim_config) = serde_json::from_value::(dkim_val.clone()) { - match mailer_security::sign_dkim( + match mailer_security::sign_dkim_auto( &raw_message, &dkim_config.domain, &dkim_config.selector, &dkim_config.private_key, + &dkim_config.key_type, ) { Ok(header) => { // Prepend DKIM header to the message diff --git a/rust/crates/mailer-security/src/dkim.rs b/rust/crates/mailer-security/src/dkim.rs index adab979..c17b546 100644 --- a/rust/crates/mailer-security/src/dkim.rs +++ b/rust/crates/mailer-security/src/dkim.rs @@ -1,4 +1,4 @@ -use mail_auth::common::crypto::{RsaKey, Sha256}; +use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256}; use mail_auth::common::headers::HeaderWriter; use mail_auth::dkim::{Canonicalization, DkimSigner}; use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator}; @@ -118,9 +118,62 @@ pub fn sign_dkim( Ok(signature.to_header()) } +/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463). +/// +/// * `raw_message` - The raw RFC 5322 message bytes +/// * `domain` - The signing domain (d= tag) +/// * `selector` - The DKIM selector (s= tag) +/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format +/// +/// Returns the DKIM-Signature header string to prepend to the message. +pub fn sign_dkim_ed25519( + raw_message: &[u8], + domain: &str, + selector: &str, + private_key_pkcs8_der: &[u8], +) -> Result { + let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der) + .map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?; + + let signature = DkimSigner::from_key(ed_key) + .domain(domain) + .selector(selector) + .headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"]) + .header_canonicalization(Canonicalization::Relaxed) + .body_canonicalization(Canonicalization::Relaxed) + .sign(raw_message) + .map_err(|e| SecurityError::Dkim(format!("Ed25519 DKIM signing failed: {}", e)))?; + + Ok(signature.to_header()) +} + +/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`. +/// +/// * `key_type` - `"rsa"` (default) or `"ed25519"` +/// * For RSA: `private_key_pem` is a PEM-encoded RSA key +/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key +pub fn sign_dkim_auto( + raw_message: &[u8], + domain: &str, + selector: &str, + private_key_pem: &str, + key_type: &str, +) -> Result { + match key_type.to_lowercase().as_str() { + "ed25519" => { + // Parse PEM to DER for Ed25519 + let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes()) + .map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?; + sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der()) + } + _ => sign_dkim(raw_message, domain, selector, private_key_pem), + } +} + /// Generate a DKIM DNS TXT record value for a given public key. /// /// Returns the value for a TXT record at `{selector}._domainkey.{domain}`. +/// `key_type` should be `"rsa"` or `"ed25519"`. pub fn dkim_dns_record_value(public_key_pem: &str) -> String { // Extract the base64 content from PEM let key_b64: String = public_key_pem @@ -132,6 +185,24 @@ pub fn dkim_dns_record_value(public_key_pem: &str) -> String { format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64) } +/// Generate a DKIM DNS TXT record value with explicit key type. +/// +/// * `key_type` - `"rsa"` or `"ed25519"` +pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String { + let key_b64: String = public_key_pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect::>() + .join(""); + + let k = match key_type.to_lowercase().as_str() { + "ed25519" => "ed25519", + _ => "rsa", + }; + + format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64) +} + #[cfg(test)] mod tests { use super::*; @@ -149,4 +220,42 @@ mod tests { let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key"); assert!(result.is_err()); } + + #[test] + fn test_sign_dkim_ed25519() { + // Generate an Ed25519 key pair using mail-auth + let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key"); + let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key"); + let _pub_key = ed_key.public_key(); + + let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody"; + let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der); + assert!(result.is_ok()); + let header = result.unwrap(); + assert!(header.contains("a=ed25519-sha256")); + assert!(header.contains("d=example.com")); + assert!(header.contains("s=ed25519sel")); + } + + #[test] + fn test_sign_dkim_auto_dispatches() { + // RSA with invalid key should error + let msg = b"From: test@example.com\r\n\r\nBody"; + let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa"); + assert!(result.is_err()); + + // Ed25519 with invalid PEM should error + let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519"); + assert!(result.is_err()); + } + + #[test] + fn test_dkim_dns_record_value_typed() { + let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----"; + let rsa_record = dkim_dns_record_value_typed(pem, "rsa"); + assert!(rsa_record.contains("k=rsa")); + + let ed_record = dkim_dns_record_value_typed(pem, "ed25519"); + assert!(ed_record.contains("k=ed25519")); + } } diff --git a/rust/crates/mailer-security/src/ip_reputation.rs b/rust/crates/mailer-security/src/ip_reputation.rs index 32425d7..0d1645e 100644 --- a/rust/crates/mailer-security/src/ip_reputation.rs +++ b/rust/crates/mailer-security/src/ip_reputation.rs @@ -1,6 +1,6 @@ use hickory_resolver::TokioResolver; use serde::{Deserialize, Serialize}; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::error::Result; @@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel { /// Check an IP against DNSBL servers. /// -/// * `ip` - The IP address to check (must be IPv4) +/// * `ip` - The IP address to check (IPv4 or IPv6) /// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults) /// * `resolver` - DNS resolver to use pub async fn check_dnsbl( @@ -91,20 +91,10 @@ pub async fn check_dnsbl( dnsbl_servers: &[&str], resolver: &TokioResolver, ) -> Result { - let ipv4 = match ip { - IpAddr::V4(v4) => v4, - IpAddr::V6(_) => { - // IPv6 DNSBL is less common; return clean result - return Ok(DnsblResult { - ip: ip.to_string(), - listed_count: 0, - listed_on: Vec::new(), - total_checked: 0, - }); - } + let reversed = match ip { + IpAddr::V4(v4) => reverse_ipv4(v4), + IpAddr::V6(v6) => reverse_ipv6(v6), }; - - let reversed = reverse_ipv4(ipv4); let total = dnsbl_servers.len(); // Query all DNSBL servers in parallel @@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String { format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) } +/// Reverse IPv6 address to nibble format for DNSBL queries. +/// +/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble. +/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2` +fn reverse_ipv6(ip: Ipv6Addr) -> String { + let segments = ip.segments(); + let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect(); + full_hex + .chars() + .rev() + .map(|c| c.to_string()) + .collect::>() + .join(".") +} + /// Heuristic IP type classification based on well-known prefix ranges. /// Same heuristics as the TypeScript IPReputationChecker. fn classify_ip(ip: IpAddr) -> IpType { @@ -272,6 +277,38 @@ mod tests { assert!(!is_valid_ipv4("not-an-ip")); } + #[test] + fn test_reverse_ipv6() { + let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap(); + assert_eq!( + reverse_ipv6(ip), + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2" + ); + } + + #[test] + fn test_reverse_ipv6_loopback() { + let ip: Ipv6Addr = "::1".parse().unwrap(); + assert_eq!( + reverse_ipv6(ip), + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0" + ); + } + + #[tokio::test] + async fn test_check_dnsbl_ipv6_runs() { + // Verify IPv6 actually goes through DNSBL queries (not skipped) + let resolver = hickory_resolver::TokioResolver::builder_tokio() + .map(|b| b.build()) + .unwrap(); + let ip: IpAddr = "::1".parse().unwrap(); + let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap(); + // Loopback should not be listed on any DNSBL + assert_eq!(result.listed_count, 0); + // But total_checked should be > 0 — proving IPv6 was actually queried + assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len()); + } + #[test] fn test_default_dnsbl_servers() { assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10); diff --git a/rust/crates/mailer-security/src/lib.rs b/rust/crates/mailer-security/src/lib.rs index c2ef4ed..59fa2e1 100644 --- a/rust/crates/mailer-security/src/lib.rs +++ b/rust/crates/mailer-security/src/lib.rs @@ -9,7 +9,7 @@ pub mod spf; pub mod verify; // Re-exports for convenience -pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult}; +pub use dkim::{dkim_dns_record_value, dkim_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult}; pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult}; pub use verify::{verify_email_security, EmailSecurityResult}; pub use error::{Result, SecurityError}; diff --git a/rust/crates/mailer-smtp/Cargo.toml b/rust/crates/mailer-smtp/Cargo.toml index 95563fc..490ce39 100644 --- a/rust/crates/mailer-smtp/Cargo.toml +++ b/rust/crates/mailer-smtp/Cargo.toml @@ -23,3 +23,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg rustls-pemfile = "2" mailparse.workspace = true webpki-roots = "0.26" +sha2.workspace = true +hmac.workspace = true +pbkdf2.workspace = true diff --git a/rust/crates/mailer-smtp/src/client/config.rs b/rust/crates/mailer-smtp/src/client/config.rs index 25c0f90..ce0924a 100644 --- a/rust/crates/mailer-smtp/src/client/config.rs +++ b/rust/crates/mailer-smtp/src/client/config.rs @@ -37,6 +37,12 @@ pub struct SmtpClientConfig { /// Maximum connections per pool. Default: 10. #[serde(default = "default_max_pool_connections")] pub max_pool_connections: usize, + + /// Accept invalid TLS certificates (expired, self-signed, wrong hostname). + /// Standard for MTA-to-MTA opportunistic TLS per RFC 7435. + /// Default: false. + #[serde(default)] + pub tls_opportunistic: bool, } /// Authentication configuration. @@ -60,8 +66,15 @@ pub struct DkimSignConfig { pub domain: String, /// DKIM selector (e.g. "default" or "mta"). pub selector: String, - /// PEM-encoded RSA private key. + /// PEM-encoded private key (RSA or Ed25519 PKCS#8). pub private_key: String, + /// Key type: "rsa" (default) or "ed25519". + #[serde(default = "default_key_type")] + pub key_type: String, +} + +fn default_key_type() -> String { + "rsa".to_string() } impl SmtpClientConfig { diff --git a/rust/crates/mailer-smtp/src/client/connection.rs b/rust/crates/mailer-smtp/src/client/connection.rs index 07cff87..2683f5b 100644 --- a/rust/crates/mailer-smtp/src/client/connection.rs +++ b/rust/crates/mailer-smtp/src/client/connection.rs @@ -117,6 +117,7 @@ pub async fn connect_tls( host: &str, port: u16, timeout_secs: u64, + tls_opportunistic: bool, ) -> Result { debug!("Connecting to {}:{} (implicit TLS)", host, port); let addr = format!("{host}:{port}"); @@ -130,7 +131,7 @@ pub async fn connect_tls( message: format!("Failed to connect to {addr}: {e}"), })?; - let tls_stream = perform_tls_handshake(tcp_stream, host).await?; + let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?; Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream))) } @@ -138,24 +139,77 @@ pub async fn connect_tls( pub async fn upgrade_to_tls( stream: ClientSmtpStream, hostname: &str, + tls_opportunistic: bool, ) -> Result { debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname); let tcp_stream = stream.into_tcp_stream()?; - let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?; + let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?; Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream))) } +/// A TLS certificate verifier that accepts any certificate. +/// Used for MTA-to-MTA opportunistic TLS per RFC 7435. +#[derive(Debug)] +struct OpportunisticVerifier; + +impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer<'_>, + _intermediates: &[rustls_pki_types::CertificateDer<'_>], + _server_name: &rustls_pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls_pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + /// Perform the TLS handshake on a TCP stream using webpki-roots. +/// When `tls_opportunistic` is true, certificate verification is skipped +/// (standard for MTA-to-MTA delivery per RFC 7435). async fn perform_tls_handshake( tcp_stream: TcpStream, hostname: &str, + tls_opportunistic: bool, ) -> Result, SmtpClientError> { - let mut root_store = rustls::RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - - let tls_config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); + let tls_config = if tls_opportunistic { + debug!("Using opportunistic TLS (no cert verification) for {}", hostname); + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(OpportunisticVerifier)) + .with_no_client_auth() + } else { + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + }; let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| { @@ -190,7 +244,7 @@ mod tests { #[tokio::test] async fn test_connect_tls_refused() { - let result = connect_tls("127.0.0.1", 19998, 2).await; + let result = connect_tls("127.0.0.1", 19998, 2, false).await; assert!(result.is_err()); } diff --git a/rust/crates/mailer-smtp/src/client/pool.rs b/rust/crates/mailer-smtp/src/client/pool.rs index b3d7499..e30d053 100644 --- a/rust/crates/mailer-smtp/src/client/pool.rs +++ b/rust/crates/mailer-smtp/src/client/pool.rs @@ -116,6 +116,7 @@ impl ConnectionPool { &self.config.host, self.config.port, self.config.connection_timeout_secs, + self.config.tls_opportunistic, ) .await? } else { @@ -139,7 +140,7 @@ impl ConnectionPool { if !self.config.secure && capabilities.starttls { protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?; stream = - super::connection::upgrade_to_tls(stream, &self.config.host).await?; + super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?; // Re-EHLO after STARTTLS — use updated capabilities for auth capabilities = protocol::send_ehlo( @@ -244,9 +245,10 @@ impl SmtpClientManager { protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?; } - // Perform the SMTP transaction + // Perform the SMTP transaction (use pipelining if server supports it) + let pipelining = conn.capabilities.pipelining; let result = - Self::perform_send(&mut conn.stream, sender, recipients, message, config).await; + Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await; // Re-acquire the pool lock and release the connection let mut pool = pool_arc.lock().await; @@ -268,30 +270,39 @@ impl SmtpClientManager { recipients: &[String], message: &[u8], config: &SmtpClientConfig, + pipelining: bool, ) -> Result { let timeout_secs = config.socket_timeout_secs; - // MAIL FROM - protocol::send_mail_from(stream, sender, timeout_secs).await?; + let (accepted, rejected) = if pipelining { + // Use pipelined envelope: MAIL FROM + all RCPT TO in one batch + let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope( + stream, sender, recipients, timeout_secs, + ).await?; + (acc, rej) + } else { + // Sequential: MAIL FROM, then each RCPT TO + protocol::send_mail_from(stream, sender, timeout_secs).await?; - // RCPT TO for each recipient - let mut accepted = Vec::new(); - let mut rejected = Vec::new(); + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); - for rcpt in recipients { - match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await { - Ok(resp) => { - if resp.is_success() { - accepted.push(rcpt.clone()); - } else { + for rcpt in recipients { + match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await { + Ok(resp) => { + if resp.is_success() { + accepted.push(rcpt.clone()); + } else { + rejected.push(rcpt.clone()); + } + } + Err(_) => { rejected.push(rcpt.clone()); } } - Err(_) => { - rejected.push(rcpt.clone()); - } } - } + (accepted, rejected) + }; // If no recipients were accepted, fail if accepted.is_empty() { @@ -339,6 +350,7 @@ impl SmtpClientManager { &config.host, config.port, config.connection_timeout_secs, + config.tls_opportunistic, ) .await? } else { diff --git a/rust/crates/mailer-smtp/src/client/protocol.rs b/rust/crates/mailer-smtp/src/client/protocol.rs index ecfed17..504d066 100644 --- a/rust/crates/mailer-smtp/src/client/protocol.rs +++ b/rust/crates/mailer-smtp/src/client/protocol.rs @@ -318,6 +318,54 @@ pub async fn send_rcpt_to( Ok(resp) } +/// Send MAIL FROM + RCPT TO commands in a single pipelined batch. +/// +/// Writes all envelope commands at once, then reads responses in order. +/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`. +pub async fn send_pipelined_envelope( + stream: &mut ClientSmtpStream, + sender: &str, + recipients: &[String], + timeout_secs: u64, +) -> Result<(bool, Vec, Vec), SmtpClientError> { + // Build the full pipelined command batch + let mut batch = format!("MAIL FROM:<{sender}>\r\n"); + for rcpt in recipients { + batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n")); + } + + // Send all commands at once + debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len()); + stream.write_all(batch.as_bytes()).await?; + stream.flush().await?; + + // Read MAIL FROM response + let mail_resp = read_response(stream, timeout_secs).await?; + if !mail_resp.is_success() { + return Err(mail_resp.to_error()); + } + + // Read RCPT TO responses + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + for rcpt in recipients { + match read_response(stream, timeout_secs).await { + Ok(resp) => { + if resp.is_success() { + accepted.push(rcpt.clone()); + } else { + rejected.push(rcpt.clone()); + } + } + Err(_) => { + rejected.push(rcpt.clone()); + } + } + } + + Ok((true, accepted, rejected)) +} + /// Send DATA command, followed by the message body with dot-stuffing. pub async fn send_data( stream: &mut ClientSmtpStream, diff --git a/rust/crates/mailer-smtp/src/command.rs b/rust/crates/mailer-smtp/src/command.rs index 9a533f5..1a3f35c 100644 --- a/rust/crates/mailer-smtp/src/command.rs +++ b/rust/crates/mailer-smtp/src/command.rs @@ -50,6 +50,7 @@ pub enum SmtpCommand { pub enum AuthMechanism { Plain, Login, + ScramSha256, } /// Errors that can occur during command parsing. @@ -218,6 +219,7 @@ fn parse_auth(rest: &str) -> Result { let mechanism = match mech_str.to_ascii_uppercase().as_str() { "PLAIN" => AuthMechanism::Plain, "LOGIN" => AuthMechanism::Login, + "SCRAM-SHA-256" => AuthMechanism::ScramSha256, other => { return Err(ParseError::SyntaxError(format!( "unsupported AUTH mechanism: {other}" diff --git a/rust/crates/mailer-smtp/src/config.rs b/rust/crates/mailer-smtp/src/config.rs index 75d58a1..66522f4 100644 --- a/rust/crates/mailer-smtp/src/config.rs +++ b/rust/crates/mailer-smtp/src/config.rs @@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize}; +/// Per-domain TLS certificate for SNI-based cert selection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsDomainCert { + /// Domain names this certificate covers (matched against SNI hostname). + pub domains: Vec, + /// Certificate chain in PEM format. + pub cert_pem: String, + /// Private key in PEM format. + pub key_pem: String, +} + /// Configuration for an SMTP server instance. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmtpServerConfig { @@ -11,10 +22,13 @@ pub struct SmtpServerConfig { pub ports: Vec, /// Port for implicit TLS (e.g. 465). None = no implicit TLS port. pub secure_port: Option, - /// TLS certificate chain in PEM format. + /// TLS certificate chain in PEM format (default cert). pub tls_cert_pem: Option, - /// TLS private key in PEM format. + /// TLS private key in PEM format (default key). pub tls_key_pem: Option, + /// Additional per-domain TLS certificates for SNI-based selection. + #[serde(default)] + pub additional_tls_certs: Vec, /// Maximum message size in bytes. pub max_message_size: u64, /// Maximum number of concurrent connections. @@ -43,6 +57,7 @@ impl Default for SmtpServerConfig { secure_port: None, tls_cert_pem: None, tls_key_pem: None, + additional_tls_certs: Vec::new(), max_message_size: 10 * 1024 * 1024, // 10 MB max_connections: 100, max_recipients: 100, diff --git a/rust/crates/mailer-smtp/src/connection.rs b/rust/crates/mailer-smtp/src/connection.rs index e17090e..4c069da 100644 --- a/rust/crates/mailer-smtp/src/connection.rs +++ b/rust/crates/mailer-smtp/src/connection.rs @@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig; use crate::data::{DataAccumulator, DataAction}; use crate::rate_limiter::RateLimiter; use crate::response::{build_capabilities, SmtpResponse}; +use crate::scram::{ScramCredentials, ScramServer}; use crate::session::{AuthState, SmtpSession}; use crate::validation; @@ -52,6 +53,13 @@ pub enum ConnectionEvent { password: String, remote_addr: String, }, + /// A SCRAM credential request — Rust needs stored credentials from TS. + ScramCredentialRequest { + correlation_id: String, + session_id: String, + username: String, + remote_addr: String, + }, } /// How email data is transported from Rust to TS. @@ -81,6 +89,16 @@ pub struct AuthResult { pub message: Option, } +/// Result of TS returning SCRAM credentials for a user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScramCredentialResult { + pub found: bool, + pub salt: Option>, + pub iterations: Option, + pub stored_key: Option>, + pub server_key: Option>, +} + /// Abstraction over plain and TLS streams. pub enum SmtpStream { Plain(BufReader), @@ -133,6 +151,14 @@ impl SmtpStream { } } + /// Check if the internal buffer has unread data (pipelined commands). + pub fn has_buffered_data(&self) -> bool { + match self { + SmtpStream::Plain(reader) => !reader.buffer().is_empty(), + SmtpStream::Tls(reader) => !reader.buffer().is_empty(), + } + } + /// Unwrap to get the raw TcpStream for STARTTLS upgrade. /// Only works on Plain streams. pub fn into_tcp_stream(self) -> Option { @@ -212,7 +238,7 @@ pub async fn handle_connection( break; } Ok(Ok(_)) => { - // Process command + // Process the first command let response = process_line( &line, &mut session, @@ -227,59 +253,123 @@ pub async fn handle_connection( ) .await; + // Check for pipelined commands in the buffer. + // Collect pipelinable responses into a batch for single write. + let mut response_batch: Vec = Vec::new(); + let mut should_break = false; + let mut starttls_signal = false; + match response { LineResult::Response(resp) => { - if stream.write_all(&resp.to_bytes()).await.is_err() { - break; - } - if stream.flush().await.is_err() { - break; - } + response_batch.extend_from_slice(&resp.to_bytes()); } LineResult::Quit(resp) => { let _ = stream.write_all(&resp.to_bytes()).await; let _ = stream.flush().await; - break; + should_break = true; } LineResult::StartTlsSignal => { - // Send 220 Ready response - let resp = SmtpResponse::new(220, "Ready to start TLS"); - if stream.write_all(&resp.to_bytes()).await.is_err() { - break; - } - if stream.flush().await.is_err() { - break; - } - // Extract TCP stream and upgrade - if let Some(tcp_stream) = stream.into_tcp_stream() { - if let Some(acceptor) = &tls_acceptor { - match acceptor.accept(tcp_stream).await { - Ok(tls_stream) => { - stream = SmtpStream::Tls(BufReader::new(tls_stream)); - session.secure = true; - // Client must re-EHLO after STARTTLS - session.state = crate::state::SmtpState::Connected; - session.client_hostname = None; - session.esmtp = false; - session.auth_state = AuthState::None; - session.envelope = Default::default(); - debug!(session_id = %session.id, "TLS upgrade successful"); - } - Err(e) => { - warn!(session_id = %session.id, error = %e, "TLS handshake failed"); - break; - } - } - } else { - break; - } - } else { - // Already TLS — shouldn't happen - break; - } + starttls_signal = true; } LineResult::NoResponse => {} LineResult::Disconnect => { + should_break = true; + } + } + + if should_break { + break; + } + + // Process additional pipelined commands from the buffer + if !starttls_signal { + while stream.has_buffered_data() { + let mut next_line = String::new(); + match stream.read_line(&mut next_line, 4096).await { + Ok(0) | Err(_) => break, + Ok(_) => { + let next_response = process_line( + &next_line, + &mut session, + &mut stream, + &config, + &rate_limiter, + &event_tx, + callback_register.as_ref(), + &tls_acceptor, + &authenticator, + &resolver, + ) + .await; + + match next_response { + LineResult::Response(resp) => { + response_batch.extend_from_slice(&resp.to_bytes()); + } + LineResult::Quit(resp) => { + response_batch.extend_from_slice(&resp.to_bytes()); + should_break = true; + break; + } + LineResult::StartTlsSignal | LineResult::Disconnect => { + // Non-pipelinable: flush batch and handle + starttls_signal = matches!(next_response, LineResult::StartTlsSignal); + should_break = matches!(next_response, LineResult::Disconnect); + break; + } + LineResult::NoResponse => {} + } + } + } + } + } + + // Flush the accumulated response batch in one write + if !response_batch.is_empty() { + if stream.write_all(&response_batch).await.is_err() { + break; + } + if stream.flush().await.is_err() { + break; + } + } + + if should_break { + break; + } + + if starttls_signal { + // Send 220 Ready response + let resp = SmtpResponse::new(220, "Ready to start TLS"); + if stream.write_all(&resp.to_bytes()).await.is_err() { + break; + } + if stream.flush().await.is_err() { + break; + } + // Extract TCP stream and upgrade + if let Some(tcp_stream) = stream.into_tcp_stream() { + if let Some(acceptor) = &tls_acceptor { + match acceptor.accept(tcp_stream).await { + Ok(tls_stream) => { + stream = SmtpStream::Tls(BufReader::new(tls_stream)); + session.secure = true; + session.state = crate::state::SmtpState::Connected; + session.client_hostname = None; + session.esmtp = false; + session.auth_state = AuthState::None; + session.envelope = Default::default(); + debug!(session_id = %session.id, "TLS upgrade successful"); + } + Err(e) => { + warn!(session_id = %session.id, error = %e, "TLS handshake failed"); + break; + } + } + } else { + break; + } + } else { break; } } @@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync { &self, correlation_id: &str, ) -> oneshot::Receiver; + + /// Register a callback for SCRAM credential lookup and return a receiver. + fn register_scram_callback( + &self, + correlation_id: &str, + ) -> oneshot::Receiver; } /// Process a single input line from the client. @@ -406,16 +502,29 @@ async fn process_line( mechanism, initial_response, } => { - handle_auth( - mechanism, - initial_response, - session, - config, - rate_limiter, - event_tx, - callback_registry, - ) - .await + if matches!(mechanism, AuthMechanism::ScramSha256) { + handle_auth_scram( + initial_response, + session, + stream, + config, + rate_limiter, + event_tx, + callback_registry, + ) + .await + } else { + handle_auth( + mechanism, + initial_response, + session, + config, + rate_limiter, + event_tx, + callback_registry, + ) + .await + } } SmtpCommand::Help(_) => { @@ -832,6 +941,217 @@ async fn handle_auth( )) } } + AuthMechanism::ScramSha256 => { + // SCRAM is handled separately in process_line; this should not be reached. + LineResult::Response(SmtpResponse::not_implemented()) + } + } +} + +/// Handle AUTH SCRAM-SHA-256 — full exchange in a single async function. +/// +/// SCRAM is a multi-step challenge-response protocol: +/// 1. Client sends client-first-message (in initial_response or after 334) +/// 2. Server requests SCRAM credentials from TS +/// 3. Server sends server-first-message (334 challenge) +/// 4. Client sends client-final-message (proof) +/// 5. Server verifies proof and responds with 235 or 535 +async fn handle_auth_scram( + initial_response: Option, + session: &mut SmtpSession, + stream: &mut SmtpStream, + config: &SmtpServerConfig, + rate_limiter: &RateLimiter, + event_tx: &mpsc::Sender, + callback_registry: &dyn CallbackRegistry, +) -> LineResult { + if !config.auth_enabled { + return LineResult::Response(SmtpResponse::not_implemented()); + } + + if session.is_authenticated() { + return LineResult::Response(SmtpResponse::bad_sequence("Already authenticated")); + } + + if !session.state.can_auth() { + return LineResult::Response(SmtpResponse::bad_sequence("Send EHLO first")); + } + + // Step 1: Get client-first-message + let client_first_b64 = match initial_response { + Some(s) if !s.is_empty() => s, + _ => { + // No initial response — send empty 334 challenge + let resp = SmtpResponse::auth_challenge(""); + if stream.write_all(&resp.to_bytes()).await.is_err() { + return LineResult::Disconnect; + } + if stream.flush().await.is_err() { + return LineResult::Disconnect; + } + // Read client-first-message + let mut line = String::new(); + let socket_timeout = Duration::from_secs(config.socket_timeout_secs); + match timeout(socket_timeout, stream.read_line(&mut line, 4096)).await { + Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect, + Ok(Ok(_)) => {} + } + let trimmed = line.trim().to_string(); + if trimmed == "*" { + return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled")); + } + trimmed + } + }; + + // Decode base64 client-first-message + let client_first_bytes = match BASE64.decode(client_first_b64.as_bytes()) { + Ok(b) => b, + Err(_) => { + return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding")); + } + }; + let client_first = match String::from_utf8(client_first_bytes) { + Ok(s) => s, + Err(_) => { + return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message")); + } + }; + + // Parse client-first-message + let mut scram = match ScramServer::from_client_first(&client_first) { + Ok(s) => s, + Err(e) => { + debug!(error = %e, "SCRAM client-first-message parse error"); + return LineResult::Response(SmtpResponse::param_error( + "Invalid SCRAM client-first-message", + )); + } + }; + + // Step 2: Request SCRAM credentials from TS + let correlation_id = uuid::Uuid::new_v4().to_string(); + let rx = callback_registry.register_scram_callback(&correlation_id); + + let event = ConnectionEvent::ScramCredentialRequest { + correlation_id: correlation_id.clone(), + session_id: session.id.clone(), + username: scram.username.clone(), + remote_addr: session.remote_addr.clone(), + }; + + if event_tx.send(event).await.is_err() { + return LineResult::Response(SmtpResponse::local_error("Internal processing error")); + } + + // Wait for credentials from TS + let cred_timeout = Duration::from_secs(5); + let cred_result = match timeout(cred_timeout, rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + warn!(correlation_id = %correlation_id, "SCRAM credential callback dropped"); + return LineResult::Response(SmtpResponse::local_error("Internal processing error")); + } + Err(_) => { + warn!(correlation_id = %correlation_id, "SCRAM credential request timed out"); + return LineResult::Response(SmtpResponse::local_error("Internal processing error")); + } + }; + + if !cred_result.found { + // User not found — fail auth (don't reveal that user doesn't exist) + session.auth_state = AuthState::None; + let exceeded = session.record_auth_failure(config.max_auth_failures); + if exceeded { + return LineResult::Quit(SmtpResponse::service_unavailable( + &config.hostname, + "Too many authentication failures", + )); + } + return LineResult::Response(SmtpResponse::auth_failed()); + } + + let creds = ScramCredentials { + salt: cred_result.salt.unwrap_or_default(), + iterations: cred_result.iterations.unwrap_or(4096), + stored_key: cred_result.stored_key.unwrap_or_default(), + server_key: cred_result.server_key.unwrap_or_default(), + }; + + // Step 3: Generate and send server-first-message + let server_first = scram.server_first_message(creds); + let server_first_b64 = BASE64.encode(server_first.as_bytes()); + + let challenge = SmtpResponse::auth_challenge(&server_first_b64); + if stream.write_all(&challenge.to_bytes()).await.is_err() { + return LineResult::Disconnect; + } + if stream.flush().await.is_err() { + return LineResult::Disconnect; + } + + // Step 4: Read client-final-message + let mut client_final_line = String::new(); + let socket_timeout = Duration::from_secs(config.socket_timeout_secs); + match timeout(socket_timeout, stream.read_line(&mut client_final_line, 4096)).await { + Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect, + Ok(Ok(_)) => {} + } + + let client_final_b64 = client_final_line.trim(); + + // Cancel if * + if client_final_b64 == "*" { + session.auth_state = AuthState::None; + return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled")); + } + + // Decode base64 client-final-message + let client_final_bytes = match BASE64.decode(client_final_b64.as_bytes()) { + Ok(b) => b, + Err(_) => { + session.auth_state = AuthState::None; + return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding")); + } + }; + let client_final = match String::from_utf8(client_final_bytes) { + Ok(s) => s, + Err(_) => { + session.auth_state = AuthState::None; + return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message")); + } + }; + + // Step 5: Verify proof + match scram.process_client_final(&client_final) { + Ok(server_final) => { + let server_final_b64 = BASE64.encode(server_final.as_bytes()); + session.auth_state = AuthState::Authenticated { + username: scram.username.clone(), + }; + LineResult::Response(SmtpResponse::new( + 235, + format!("2.7.0 Authentication successful {}", server_final_b64), + )) + } + Err(e) => { + debug!(error = %e, "SCRAM proof verification failed"); + session.auth_state = AuthState::None; + let exceeded = session.record_auth_failure(config.max_auth_failures); + if exceeded { + if !rate_limiter.check_auth_failure(&session.remote_addr) { + return LineResult::Quit(SmtpResponse::service_unavailable( + &config.hostname, + "Too many authentication failures", + )); + } + return LineResult::Quit(SmtpResponse::service_unavailable( + &config.hostname, + "Too many authentication failures", + )); + } + LineResult::Response(SmtpResponse::auth_failed()) + } } } diff --git a/rust/crates/mailer-smtp/src/lib.rs b/rust/crates/mailer-smtp/src/lib.rs index 1e39059..720e17c 100644 --- a/rust/crates/mailer-smtp/src/lib.rs +++ b/rust/crates/mailer-smtp/src/lib.rs @@ -19,6 +19,7 @@ pub mod connection; pub mod data; pub mod rate_limiter; pub mod response; +pub mod scram; pub mod server; pub mod session; pub mod state; diff --git a/rust/crates/mailer-smtp/src/response.rs b/rust/crates/mailer-smtp/src/response.rs index 502bfd8..87fd80b 100644 --- a/rust/crates/mailer-smtp/src/response.rs +++ b/rust/crates/mailer-smtp/src/response.rs @@ -196,7 +196,7 @@ pub fn build_capabilities( caps.push("STARTTLS".to_string()); } if auth_available { - caps.push("AUTH PLAIN LOGIN".to_string()); + caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()); } caps } @@ -253,7 +253,7 @@ mod tests { let caps = build_capabilities(10485760, true, false, true); assert!(caps.contains(&"SIZE 10485760".to_string())); assert!(caps.contains(&"STARTTLS".to_string())); - assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string())); + assert!(caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string())); assert!(caps.contains(&"PIPELINING".to_string())); } @@ -262,7 +262,7 @@ mod tests { // When already secure, STARTTLS should NOT be advertised let caps = build_capabilities(10485760, true, true, false); assert!(!caps.contains(&"STARTTLS".to_string())); - assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string())); + assert!(!caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string())); } #[test] diff --git a/rust/crates/mailer-smtp/src/scram.rs b/rust/crates/mailer-smtp/src/scram.rs new file mode 100644 index 0000000..1df4dc8 --- /dev/null +++ b/rust/crates/mailer-smtp/src/scram.rs @@ -0,0 +1,342 @@ +//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677). +//! +//! Implements the server side of the SCRAM-SHA-256 SASL mechanism, +//! a challenge-response protocol that avoids transmitting cleartext passwords. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +/// Pre-computed SCRAM credentials for a user (derived from password). +#[derive(Debug, Clone)] +pub struct ScramCredentials { + pub salt: Vec, + pub iterations: u32, + pub stored_key: Vec, + pub server_key: Vec, +} + +/// Server-side SCRAM state machine. +pub struct ScramServer { + /// Username extracted from client-first-message. + pub username: String, + /// Full combined nonce (client + server). + combined_nonce: String, + /// Server nonce portion (used in tests for verification). + #[allow(dead_code)] + server_nonce: String, + /// Stored credentials (set after TS responds). + credentials: Option, + /// The server-first-message (for auth message construction). + server_first: String, + /// The client-first-message-bare (for auth message construction). + client_first_bare: String, +} + +impl ScramServer { + /// Process the client-first-message. + /// + /// Parses the client nonce and username, generates a server nonce, + /// and returns a partial state that needs credentials to produce the + /// server-first-message. + pub fn from_client_first(client_first: &str) -> Result { + // client-first-message = gs2-header client-first-message-bare + // gs2-header = "n,," (no channel binding) + // client-first-message-bare = "n=username,r=nonce" + let bare = if let Some(rest) = client_first.strip_prefix("n,,") { + rest + } else if let Some(rest) = client_first.strip_prefix("y,,") { + rest + } else { + return Err("Invalid SCRAM gs2-header".into()); + }; + + let mut username = String::new(); + let mut client_nonce = String::new(); + + for part in bare.split(',') { + if let Some(val) = part.strip_prefix("n=") { + username = val.to_string(); + } else if let Some(val) = part.strip_prefix("r=") { + client_nonce = val.to_string(); + } + } + + if username.is_empty() || client_nonce.is_empty() { + return Err("Missing username or nonce in client-first-message".into()); + } + + // Generate server nonce + let server_nonce: String = (0..24) + .map(|_| { + let idx = (rand_byte() as usize) % 62; + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char + }) + .collect(); + + let combined_nonce = format!("{}{}", client_nonce, server_nonce); + + Ok(ScramServer { + username, + combined_nonce, + server_nonce, + credentials: None, + server_first: String::new(), + client_first_bare: bare.to_string(), + }) + } + + /// Set the credentials and produce the server-first-message. + pub fn server_first_message(&mut self, creds: ScramCredentials) -> String { + let salt_b64 = BASE64.encode(&creds.salt); + let server_first = format!( + "r={},s={},i={}", + self.combined_nonce, salt_b64, creds.iterations + ); + + self.server_first = server_first.clone(); + self.credentials = Some(creds); + server_first + } + + /// Process the client-final-message and verify the proof. + /// + /// Returns the server-final-message (containing ServerSignature) on success, + /// or an error string on failure. + pub fn process_client_final(&mut self, client_final: &str) -> Result { + let creds = self.credentials.as_ref().ok_or("No credentials set")?; + + // Parse client-final-message + // Format: c=biws,r=,p= + let mut channel_binding = String::new(); + let mut nonce = String::new(); + let mut proof_b64 = String::new(); + + for part in client_final.split(',') { + if let Some(val) = part.strip_prefix("c=") { + channel_binding = val.to_string(); + } else if let Some(val) = part.strip_prefix("r=") { + nonce = val.to_string(); + } else if let Some(val) = part.strip_prefix("p=") { + proof_b64 = val.to_string(); + } + } + + // Verify nonce matches + if nonce != self.combined_nonce { + return Err("Nonce mismatch".into()); + } + + // Build the client-final-message-without-proof + let client_final_without_proof = format!("c={},r={}", channel_binding, nonce); + + // Complete the auth message + let auth_message = format!( + "{},{},{}", + self.client_first_bare, self.server_first, client_final_without_proof + ); + + // Verify client proof + let client_proof = BASE64.decode(proof_b64.as_bytes()) + .map_err(|_| "Invalid base64 in client proof")?; + + // ClientSignature = HMAC(StoredKey, AuthMessage) + let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes()); + + // ClientKey = ClientProof XOR ClientSignature + if client_proof.len() != client_signature.len() { + return Err("Client proof length mismatch".into()); + } + let client_key: Vec = client_proof + .iter() + .zip(client_signature.iter()) + .map(|(a, b)| a ^ b) + .collect(); + + // StoredKey = H(ClientKey) + let computed_stored_key = sha256(&client_key); + + // Verify: computed StoredKey must match the stored StoredKey + if computed_stored_key != creds.stored_key { + return Err("Authentication failed".into()); + } + + // Generate ServerSignature for mutual authentication + let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes()); + let server_sig_b64 = BASE64.encode(&server_signature); + + Ok(format!("v={}", server_sig_b64)) + } +} + +/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute). +pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials { + // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations) + let mut salted_password = [0u8; 32]; + pbkdf2::pbkdf2_hmac::( + password.as_bytes(), + salt, + iterations, + &mut salted_password, + ); + + // ClientKey = HMAC(SaltedPassword, "Client Key") + let client_key = hmac_sha256(&salted_password, b"Client Key"); + + // StoredKey = H(ClientKey) + let stored_key = sha256(&client_key); + + // ServerKey = HMAC(SaltedPassword, "Server Key") + let server_key = hmac_sha256(&salted_password, b"Server Key"); + + ScramCredentials { + salt: salt.to_vec(), + iterations, + stored_key, + server_key, + } +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn sha256(data: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().to_vec() +} + +/// Simple random byte using system randomness. +fn rand_byte() -> u8 { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + let state = RandomState::new(); + let mut hasher = state.build_hasher(); + hasher.write_u64(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64); + hasher.finish() as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scram_full_exchange() { + let password = "pencil"; + let salt = b"test-salt-1234"; + let iterations = 4096; + + // Pre-compute server-side credentials from password + let creds = compute_scram_credentials(password, salt, iterations); + + // 1. Client sends client-first-message + let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO"; + let mut server = ScramServer::from_client_first(client_first).unwrap(); + assert_eq!(server.username, "user"); + + // 2. Server responds with server-first-message + let server_first = server.server_first_message(creds.clone()); + assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce))); + assert!(server_first.contains("s=")); + assert!(server_first.contains("i=4096")); + + // 3. Client computes proof + // SaltedPassword + let mut salted_password = [0u8; 32]; + pbkdf2::pbkdf2_hmac::( + password.as_bytes(), + salt, + iterations, + &mut salted_password, + ); + + let client_key = hmac_sha256(&salted_password, b"Client Key"); + let stored_key = sha256(&client_key); + + let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO"; + let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce); + let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof); + + let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes()); + let client_proof: Vec = client_key + .iter() + .zip(client_signature.iter()) + .map(|(a, b)| a ^ b) + .collect(); + let proof_b64 = BASE64.encode(&client_proof); + + let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64); + + // 4. Server verifies proof + let result = server.process_client_final(&client_final); + assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err()); + let server_final = result.unwrap(); + assert!(server_final.starts_with("v=")); + } + + #[test] + fn test_scram_wrong_password() { + let password = "pencil"; + let wrong_password = "wrong"; + let salt = b"test-salt"; + let iterations = 4096; + + let creds = compute_scram_credentials(password, salt, iterations); + + let client_first = "n,,n=user,r=clientnonce123"; + let mut server = ScramServer::from_client_first(client_first).unwrap(); + let server_first = server.server_first_message(creds); + + // Client computes proof with wrong password + let mut salted_password = [0u8; 32]; + pbkdf2::pbkdf2_hmac::( + wrong_password.as_bytes(), + salt, + iterations, + &mut salted_password, + ); + + let client_key = hmac_sha256(&salted_password, b"Client Key"); + let stored_key = sha256(&client_key); + + let client_first_bare = "n=user,r=clientnonce123"; + let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce); + let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof); + + let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes()); + let client_proof: Vec = client_key + .iter() + .zip(client_signature.iter()) + .map(|(a, b)| a ^ b) + .collect(); + let proof_b64 = BASE64.encode(&client_proof); + + let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64); + let result = server.process_client_final(&client_final); + assert!(result.is_err()); + } + + #[test] + fn test_compute_scram_credentials() { + let creds = compute_scram_credentials("password", b"salt", 4096); + assert_eq!(creds.salt, b"salt"); + assert_eq!(creds.iterations, 4096); + assert_eq!(creds.stored_key.len(), 32); + assert_eq!(creds.server_key.len(), 32); + } + + #[test] + fn test_invalid_client_first() { + assert!(ScramServer::from_client_first("invalid").is_err()); + assert!(ScramServer::from_client_first("n,,").is_err()); + } +} diff --git a/rust/crates/mailer-smtp/src/server.rs b/rust/crates/mailer-smtp/src/server.rs index bc2b0d4..11acff5 100644 --- a/rust/crates/mailer-smtp/src/server.rs +++ b/rust/crates/mailer-smtp/src/server.rs @@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter}; use hickory_resolver::TokioResolver; use mailer_security::MessageAuthenticator; use rustls_pki_types::{CertificateDer, PrivateKeyDer}; +use std::collections::HashMap; use std::io::BufReader; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; @@ -263,6 +264,69 @@ async fn accept_loop( } } +/// SNI-based certificate resolver that selects the appropriate TLS certificate +/// based on the client's requested hostname. +struct SniCertResolver { + /// Domain -> certified key mapping. + certs: HashMap>, + /// Default certificate for non-matching SNI or missing SNI. + default: Arc, +} + +impl std::fmt::Debug for SniCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SniCertResolver") + .field("domains", &self.certs.keys().collect::>()) + .finish() + } +} + +impl rustls::server::ResolvesServerCert for SniCertResolver { + fn resolve( + &self, + client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + if let Some(sni) = client_hello.server_name() { + let sni_lower = sni.to_lowercase(); + if let Some(key) = self.certs.get(&sni_lower) { + return Some(key.clone()); + } + } + Some(self.default.clone()) + } +} + +/// Parse a PEM cert+key pair into a `CertifiedKey`. +fn parse_certified_key( + cert_pem: &str, + key_pem: &str, +) -> Result> { + let certs: Vec> = { + let mut reader = BufReader::new(cert_pem.as_bytes()); + rustls_pemfile::certs(&mut reader).collect::, _>>()? + }; + if certs.is_empty() { + return Err("No certificates found in PEM".into()); + } + + let key: PrivateKeyDer<'static> = { + let mut reader = BufReader::new(key_pem.as_bytes()); + let mut keys = Vec::new(); + for item in rustls_pemfile::read_all(&mut reader) { + match item? { + rustls_pemfile::Item::Pkcs8Key(key) => keys.push(PrivateKeyDer::Pkcs8(key)), + rustls_pemfile::Item::Pkcs1Key(key) => keys.push(PrivateKeyDer::Pkcs1(key)), + rustls_pemfile::Item::Sec1Key(key) => keys.push(PrivateKeyDer::Sec1(key)), + _ => {} + } + } + keys.into_iter().next().ok_or("No private key found in PEM")? + }; + + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?; + Ok(rustls::sign::CertifiedKey::new(certs, signing_key)) +} + /// Build a TLS acceptor from PEM cert/key strings. fn build_tls_acceptor( config: &SmtpServerConfig, @@ -311,9 +375,42 @@ fn build_tls_acceptor( .ok_or("No private key found in PEM")? }; - let tls_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key)?; + // If additional TLS certs are configured, use SNI-based resolution + let tls_config = if config.additional_tls_certs.is_empty() { + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)? + } else { + // Build default certified key + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?; + let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key)); + + // Build per-domain certs + let mut domain_certs = HashMap::new(); + for domain_cert in &config.additional_tls_certs { + match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) { + Ok(ck) => { + let ck = Arc::new(ck); + for domain in &domain_cert.domains { + domain_certs.insert(domain.to_lowercase(), ck.clone()); + } + info!("SNI cert loaded for domains: {:?}", domain_cert.domains); + } + Err(e) => { + warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e); + } + } + } + + let resolver = SniCertResolver { + certs: domain_certs, + default: default_ck, + }; + + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(resolver)) + }; Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config))) } diff --git a/test/test.mta.delivery.node.ts b/test/test.mta.delivery.node.ts new file mode 100644 index 0000000..0f6d426 --- /dev/null +++ b/test/test.mta.delivery.node.ts @@ -0,0 +1,295 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js'; +import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js'; +import { Email } from '../ts/mail/core/classes.email.js'; +import * as net from 'net'; +import * as dns from 'dns'; + +const storageMap = new Map(); +const mockDcRouter = { + storageManager: { + get: async (key: string) => storageMap.get(key) || null, + set: async (key: string, value: string) => { storageMap.set(key, value); }, + }, +}; + +let server: UnifiedEmailServer; +let bridgeAvailable = false; +let mockSmtpServer: net.Server; + +/** + * Create a minimal mock SMTP server that accepts any email. + */ +function createMockSmtpServer(port: number): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer((socket) => { + socket.write('220 mock-smtp.local ESMTP MockServer\r\n'); + + let inData = false; + let dataBuffer = ''; + + socket.on('data', (chunk) => { + const input = chunk.toString(); + + if (inData) { + dataBuffer += input; + if (dataBuffer.includes('\r\n.\r\n')) { + inData = false; + dataBuffer = ''; + socket.write('250 2.0.0 Ok: queued\r\n'); + } + return; + } + + const lines = input.split('\r\n').filter((l: string) => l.length > 0); + for (const line of lines) { + const cmd = line.toUpperCase(); + if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) { + socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`); + } else if (cmd.startsWith('MAIL FROM')) { + socket.write('250 2.1.0 Ok\r\n'); + } else if (cmd.startsWith('RCPT TO')) { + socket.write('250 2.1.5 Ok\r\n'); + } else if (cmd === 'DATA') { + inData = true; + dataBuffer = ''; + socket.write('354 End data with .\r\n'); + } else if (cmd === 'QUIT') { + socket.write('221 2.0.0 Bye\r\n'); + socket.end(); + } else if (cmd === 'RSET') { + socket.write('250 2.0.0 Ok\r\n'); + } + } + }); + }); + + srv.listen(port, '127.0.0.1', () => { + resolve(srv); + }); + + srv.on('error', reject); + }); +} + +// Store original resolveMx so we can restore it +const originalResolveMx = dns.promises.Resolver.prototype.resolveMx; + +tap.test('setup - start server and mock SMTP', async () => { + RustSecurityBridge.resetInstance(); + + server = new UnifiedEmailServer(mockDcRouter, { + ports: [10425], + hostname: 'test.mta.local', + domains: [ + { domain: 'mta-test.com', dnsMode: 'forward' }, + ], + routes: [ + { + name: 'mta-route', + priority: 10, + match: { recipients: '*@mta-test.com' }, + action: { type: 'deliver' }, + }, + { + name: 'process-route', + priority: 20, + match: { recipients: '*@process-test.com' }, + action: { + type: 'process', + options: { + contentScanning: true, + scanners: [{ type: 'spam' }], + }, + }, + }, + ], + }); + + try { + await server.start(); + bridgeAvailable = true; + } catch (err) { + console.log(`SKIP: Server failed to start — ${(err as Error).message}`); + console.log('Build the Rust binary with: cd rust && cargo build --release'); + return; + } + + mockSmtpServer = await createMockSmtpServer(10426); +}); + +tap.test('MX resolution for a public domain', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + // Use the delivery system's resolveMxForDomain via a quick DNS lookup + const resolver = new dns.promises.Resolver(); + try { + const records = await resolver.resolveMx('gmail.com'); + expect(records).toBeTruthy(); + expect(records.length).toBeGreaterThan(0); + // Each record should have exchange and priority + for (const rec of records) { + expect(typeof rec.exchange).toEqual('string'); + expect(typeof rec.priority).toEqual('number'); + } + console.log(`Resolved ${records.length} MX records for gmail.com`); + } catch (err) { + console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`); + } +}); + +tap.test('group recipients by domain', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + // Test the grouping logic directly + const recipients = [ + 'alice@example.com', + 'bob@example.com', + 'carol@other.org', + 'dave@example.com', + 'eve@other.org', + ]; + + const groups = new Map(); + for (const rcpt of recipients) { + const domain = rcpt.split('@')[1]?.toLowerCase(); + if (!domain) continue; + const list = groups.get(domain) || []; + list.push(rcpt); + groups.set(domain, list); + } + + expect(groups.size).toEqual(2); + expect(groups.get('example.com')!.length).toEqual(3); + expect(groups.get('other.org')!.length).toEqual(2); +}); + +tap.test('MTA delivery to mock SMTP server via mocked MX', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + // Mock dns.promises.Resolver.resolveMx to return 127.0.0.1 + dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) { + return [{ exchange: '127.0.0.1', priority: 10 }]; + }; + + const email = new Email({ + from: 'sender@mta-test.com', + to: 'recipient@target-domain.com', + subject: 'MTA Delivery Test', + text: 'Testing MTA delivery with MX resolution.', + }); + + // Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1) + // But the real test is the delivery system's handleMtaDelivery, which we test + // by sending through the server's outbound path with the mock MX. + + // Direct test: resolve MX then send + const resolver = new dns.promises.Resolver(); + const mxRecords = await resolver.resolveMx('target-domain.com'); + expect(mxRecords[0].exchange).toEqual('127.0.0.1'); + + // Send via the resolved MX host to the mock SMTP server on port 10425 + // Note: MTA delivery uses port 25 by default, but our mock is on 10425. + // We test the sendOutboundEmail path directly with the mock MX host. + const result = await server.sendOutboundEmail('127.0.0.1', 10426, email); + expect(result).toBeTruthy(); + expect(result.accepted.length).toBeGreaterThan(0); + expect(result.response).toInclude('2.0.0'); + + // Restore original resolveMx + dns.promises.Resolver.prototype.resolveMx = originalResolveMx; +}); + +tap.test('MTA delivery - connection refused to unreachable MX', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + const email = new Email({ + from: 'sender@mta-test.com', + to: 'recipient@unreachable-domain.com', + subject: 'Connection Refused MX Test', + text: 'This should fail — no server at the target.', + }); + + // Send to a port that nothing is listening on + try { + await server.sendOutboundEmail('127.0.0.1', 59789, email); + throw new Error('Expected sendOutboundEmail to fail'); + } catch (err: any) { + expect(err).toBeTruthy(); + expect(err.message.length).toBeGreaterThan(0); + console.log(`Got expected error: ${err.message}`); + } +}); + +tap.test('MTA delivery with multiple recipients across domains', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + // Mock MX to return 127.0.0.1 for all domains + dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) { + return [{ exchange: '127.0.0.1', priority: 10 }]; + }; + + const email = new Email({ + from: 'sender@mta-test.com', + to: ['alice@domain-a.com', 'bob@domain-b.com'], + subject: 'Multi-Domain MTA Test', + text: 'Testing delivery to multiple domains.', + }); + + // Send to each recipient's domain individually (simulating MTA behavior) + for (const recipient of email.to) { + const singleEmail = new Email({ + from: email.from, + to: recipient, + subject: email.subject, + text: email.text, + }); + const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail); + expect(result.accepted.length).toEqual(1); + } + + // Restore original resolveMx + dns.promises.Resolver.prototype.resolveMx = originalResolveMx; +}); + +tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => { + if (!bridgeAvailable) { console.log('SKIP'); return; } + + // Resolve real MX records for task.vc + const resolver = new dns.promises.Resolver(); + const mxRecords = await resolver.resolveMx('task.vc'); + expect(mxRecords.length).toBeGreaterThan(0); + const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange; + console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`); + + const email = new Email({ + from: 'test@mta-test.com', + to: 'hello@task.vc', + subject: `MTA E2E Test — ${new Date().toISOString()}`, + text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.', + }); + + const result = await server.sendOutboundEmail(mxHost, 25, email); + expect(result).toBeTruthy(); + expect(result.accepted).toBeTruthy(); + expect(result.accepted.length).toEqual(1); + expect(result.accepted[0]).toEqual('hello@task.vc'); + expect(result.response).toInclude('2.0.0'); + console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`); +}); + +tap.test('cleanup - stop server and mock SMTP', async () => { + // Restore MX resolver in case it wasn't restored + dns.promises.Resolver.prototype.resolveMx = originalResolveMx; + + // Force-close mock server (destroy all open sockets) + if (mockSmtpServer) { + mockSmtpServer.close(); + } + if (bridgeAvailable) { + await server.stop(); + } + await tap.stopForcefully(); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 76f99bc..109ae38 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartmta', - version: '5.0.0', + version: '5.1.0', description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.' } diff --git a/ts/mail/delivery/classes.delivery.system.ts b/ts/mail/delivery/classes.delivery.system.ts index c9c57d3..2d9c31f 100644 --- a/ts/mail/delivery/classes.delivery.system.ts +++ b/ts/mail/delivery/classes.delivery.system.ts @@ -7,10 +7,12 @@ import { SecurityEventType } from '../../security/index.js'; import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js'; -import type { Email } from '../core/classes.email.js'; +import { Email } from '../core/classes.email.js'; import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; +const dns = plugins.dns; + /** * Delivery status enumeration */ @@ -480,39 +482,119 @@ export class MultiModeDeliverySystem extends EventEmitter { } } + /** + * Resolve MX records for a domain, sorted by priority (lowest first). + * Falls back to the domain itself as an A record per RFC 5321. + */ + private async resolveMxForDomain(domain: string): Promise> { + const resolver = new dns.promises.Resolver(); + try { + const mxRecords = await resolver.resolveMx(domain); + return mxRecords.sort((a, b) => a.priority - b.priority); + } catch (err) { + logger.log('warn', `No MX records for ${domain}, falling back to A record`); + return [{ exchange: domain, priority: 0 }]; + } + } + + /** + * Group recipient addresses by their domain part. + */ + private groupRecipientsByDomain(recipients: string[]): Map { + const groups = new Map(); + for (const rcpt of recipients) { + const domain = rcpt.split('@')[1]?.toLowerCase(); + if (!domain) continue; + const list = groups.get(domain) || []; + list.push(rcpt); + groups.set(domain, list); + } + return groups; + } + /** * Default handler for MTA mode delivery * @param item Queue item */ private async handleMtaDelivery(item: IQueueItem): Promise { logger.log('info', `MTA delivery for item ${item.id}`); - + const email = item.processingResult as Email; - const route = item.route; - - try { - // Apply DKIM signing if configured in the route - if (item.route?.action.options?.mtaOptions?.dkimSign) { - await this.applyDkimSigning(email, item.route.action.options.mtaOptions); - } - - // In a full implementation, this would use the MTA service - // For now, we'll simulate a successful delivery - - logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`); - - // Note: The MTA implementation would handle actual local delivery - - // Simulate successful delivery - return { - recipients: email.getAllRecipients().length, - subject: email.subject, - dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign - }; - } catch (error: any) { - logger.log('error', `Failed to process email in MTA mode: ${error.message}`); - throw error; + + if (!this.emailServer) { + throw new Error('No email server available for MTA delivery'); } + + // Build DKIM options from route config + const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign + ? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1]) + : undefined; + const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default'; + + const allRecipients = email.getAllRecipients(); + if (allRecipients.length === 0) { + throw new Error('No recipients specified for MTA delivery'); + } + + const domainGroups = this.groupRecipientsByDomain(allRecipients); + const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = []; + + for (const [domain, recipients] of domainGroups) { + const mxHosts = await this.resolveMxForDomain(domain); + let delivered = false; + let lastError: string | undefined; + + for (const mx of mxHosts) { + try { + logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`); + + // Create a temporary Email scoped to this domain's recipients + const domainEmail = new Email({ + from: email.from, + to: recipients.filter(r => email.to.includes(r)), + cc: recipients.filter(r => (email.cc || []).includes(r)), + bcc: recipients.filter(r => (email.bcc || []).includes(r)), + subject: email.subject, + text: email.text, + html: email.html, + }); + + const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, { + dkimDomain, + dkimSelector, + }); + + results.push({ + domain, + success: true, + accepted: result.accepted, + rejected: result.rejected, + }); + delivered = true; + logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`); + break; + } catch (err: any) { + lastError = err.message; + logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`); + } + } + + if (!delivered) { + results.push({ domain, success: false, error: lastError }); + logger.log('error', `MTA: all MX hosts failed for ${domain}`); + } + } + + const allFailed = results.every(r => !r.success); + if (allFailed) { + const summary = results.map(r => `${r.domain}: ${r.error}`).join('; '); + throw new Error(`MTA delivery failed for all domains: ${summary}`); + } + + return { + recipients: allRecipients.length, + domainResults: results, + }; } /** @@ -584,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter { await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {}); } - logger.log('info', `Email successfully processed in store-and-forward mode`); - - // Simulate successful delivery - return { - recipients: email.getAllRecipients().length, - subject: email.subject, - scanned: !!route?.action.options?.contentScanning, - transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0), - dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim) - }; + logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`); + + // After scanning + transformations, deliver via MTA + return await this.handleMtaDelivery(item); } catch (error: any) { logger.log('error', `Failed to process email: ${error.message}`); throw error; diff --git a/ts/mail/routing/classes.dkim.manager.ts b/ts/mail/routing/classes.dkim.manager.ts index 1ba7475..7c86d39 100644 --- a/ts/mail/routing/classes.dkim.manager.ts +++ b/ts/mail/routing/classes.dkim.manager.ts @@ -131,11 +131,15 @@ export class DkimManager { const { privateKey } = await this.dkimCreator.readDKIMKeys(domain); const rawEmail = email.toRFC822String(); + // Detect key type from PEM header + const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa'; + const signResult = await this.rustBridge.signDkim({ rawMessage: rawEmail, domain, selector, privateKey, + keyType, }); if (signResult.header) { diff --git a/ts/mail/routing/classes.email.action.executor.ts b/ts/mail/routing/classes.email.action.executor.ts index 5d2190c..6735e3f 100644 --- a/ts/mail/routing/classes.email.action.executor.ts +++ b/ts/mail/routing/classes.email.action.executor.ts @@ -18,6 +18,7 @@ export interface IActionExecutorDeps { auth?: { user: string; pass: string }; dkimDomain?: string; dkimSelector?: string; + tlsOpportunistic?: boolean; }) => Promise; bounceManager: BounceManager; deliveryQueue: UnifiedDeliveryQueue; diff --git a/ts/mail/routing/classes.unified.email.server.ts b/ts/mail/routing/classes.unified.email.server.ts index 88800fb..a8b367f 100644 --- a/ts/mail/routing/classes.unified.email.server.ts +++ b/ts/mail/routing/classes.unified.email.server.ts @@ -285,6 +285,7 @@ export class UnifiedEmailServer extends EventEmitter { auth?: { user: string; pass: string }; dkimDomain?: string; dkimSelector?: string; + tlsOpportunistic?: boolean; }): Promise { // Build DKIM config if domain has keys let dkim: { domain: string; selector: string; privateKey: string } | undefined; @@ -321,6 +322,7 @@ export class UnifiedEmailServer extends EventEmitter { socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000), poolKey: `${host}:${port}`, maxPoolConnections: this.options.outbound?.maxConnections || 10, + tlsOpportunistic: options?.tlsOpportunistic ?? (port === 25), }); } @@ -416,6 +418,22 @@ export class UnifiedEmailServer extends EventEmitter { } } }); + + this.rustBridge.onScramCredentialRequest(async (data) => { + try { + await this.handleScramCredentialRequest(data); + } catch (err) { + logger.log('error', `Error handling SCRAM credential request: ${(err as Error).message}`); + try { + await this.rustBridge.sendScramCredentialResult({ + correlationId: data.correlationId, + found: false, + }); + } catch (sendErr) { + logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`); + } + } + }); } private async startSmtpServer(): Promise { @@ -622,6 +640,53 @@ export class UnifiedEmailServer extends EventEmitter { } } + /** + * Handle a SCRAM credential request from the Rust SMTP server. + * Computes SCRAM-SHA-256 credentials from the stored password for the given user. + */ + private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise { + const { correlationId, username, remoteAddr } = data; + const crypto = await import('crypto'); + + logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`); + + const users = this.options.auth?.users || []; + const matched = users.find(u => u.username === username); + + if (!matched) { + await this.rustBridge.sendScramCredentialResult({ + correlationId, + found: false, + }); + return; + } + + // Compute SCRAM-SHA-256 credentials from plaintext password + const salt = crypto.randomBytes(16); + const iterations = 4096; + + // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32) + const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256'); + + // ClientKey = HMAC-SHA256(SaltedPassword, "Client Key") + const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest(); + + // StoredKey = SHA256(ClientKey) + const storedKey = crypto.createHash('sha256').update(clientKey).digest(); + + // ServerKey = HMAC-SHA256(SaltedPassword, "Server Key") + const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest(); + + await this.rustBridge.sendScramCredentialResult({ + correlationId, + found: true, + salt: salt.toString('base64'), + iterations, + storedKey: storedKey.toString('base64'), + serverKey: serverKey.toString('base64'), + }); + } + /** * Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results * or falling back to IPC call if no pre-computed results are available. diff --git a/ts/mail/security/classes.dkimcreator.ts b/ts/mail/security/classes.dkimcreator.ts index b2b0a6b..2cccec4 100644 --- a/ts/mail/security/classes.dkimcreator.ts +++ b/ts/mail/security/classes.dkimcreator.ts @@ -115,7 +115,7 @@ export class DKIMCreator { } } - // Create a DKIM key pair - changed to public for API access + // Create an RSA DKIM key pair - changed to public for API access public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> { const { privateKey, publicKey } = await generateKeyPair('rsa', { modulusLength: 2048, @@ -126,6 +126,16 @@ export class DKIMCreator { return { privateKey, publicKey }; } + // Create an Ed25519 DKIM key pair (RFC 8463) + public async createEd25519Keys(): Promise<{ privateKey: string; publicKey: string }> { + const { privateKey, publicKey } = await generateKeyPair('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + return { privateKey, publicKey }; + } + // Store a DKIM key pair - uses storage manager if available, else disk public async storeDKIMKeys( privateKey: string, @@ -176,8 +186,11 @@ export class DKIMCreator { .replace(pemFooter, '') .replace(/\n/g, ''); + // Detect key type from PEM header + const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa'; + // Now generate the DKIM DNS TXT record - const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; + const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`; return { name: `mta._domainkey.${domainArg}`, @@ -375,8 +388,11 @@ export class DKIMCreator { .replace(pemFooter, '') .replace(/\n/g, ''); + // Detect key type from PEM header + const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa'; + // Generate the DKIM DNS TXT record - const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; + const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`; return { name: `${selector}._domainkey.${domain}`, diff --git a/ts/security/classes.rustsecuritybridge.ts b/ts/security/classes.rustsecuritybridge.ts index d9fb3fc..2bafebe 100644 --- a/ts/security/classes.rustsecuritybridge.ts +++ b/ts/security/classes.rustsecuritybridge.ts @@ -95,11 +95,12 @@ interface ISmtpSendOptions { domain?: string; auth?: { user: string; pass: string; method?: string }; email: IOutboundEmail; - dkim?: { domain: string; selector: string; privateKey: string }; + dkim?: { domain: string; selector: string; privateKey: string; keyType?: string }; connectionTimeoutSecs?: number; socketTimeoutSecs?: number; poolKey?: string; maxPoolConnections?: number; + tlsOpportunistic?: boolean; } interface ISmtpSendRawOptions { @@ -147,6 +148,7 @@ interface ISmtpServerConfig { securePort?: number; tlsCertPem?: string; tlsKeyPem?: string; + additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>; maxMessageSize?: number; maxConnections?: number; maxRecipients?: number; @@ -193,6 +195,13 @@ interface IAuthRequestEvent { remoteAddr: string; } +interface IScramCredentialRequestEvent { + correlationId: string; + sessionId: string; + username: string; + remoteAddr: string; +} + /** * Type-safe command map for the mailer-bin IPC bridge. */ @@ -222,7 +231,7 @@ type TMailerCommands = { result: IDkimVerificationResult[]; }; signDkim: { - params: { rawMessage: string; domain: string; selector?: string; privateKey: string }; + params: { rawMessage: string; domain: string; selector?: string; privateKey: string; keyType?: string }; result: { header: string; signedMessage: string }; }; checkSpf: { @@ -273,6 +282,17 @@ type TMailerCommands = { }; result: { resolved: boolean }; }; + scramCredentialResult: { + params: { + correlationId: string; + found: boolean; + salt?: string; + iterations?: number; + storedKey?: string; + serverKey?: string; + }; + result: { resolved: boolean }; + }; configureRateLimits: { params: IRateLimitConfig; result: { configured: boolean }; @@ -706,12 +726,13 @@ export class RustSecurityBridge extends EventEmitter { return this.bridge.sendCommand('verifyDkim', { rawMessage }); } - /** Sign an email with DKIM. */ + /** Sign an email with DKIM (RSA or Ed25519). */ public async signDkim(opts: { rawMessage: string; domain: string; selector?: string; privateKey: string; + keyType?: string; }): Promise<{ header: string; signedMessage: string }> { this.ensureRunning(); return this.bridge.sendCommand('signDkim', opts); @@ -829,6 +850,22 @@ export class RustSecurityBridge extends EventEmitter { await this.bridge.sendCommand('authResult', opts); } + /** + * Send SCRAM credentials back to the Rust SMTP server. + * Values (salt, storedKey, serverKey) must be base64-encoded. + */ + public async sendScramCredentialResult(opts: { + correlationId: string; + found: boolean; + salt?: string; + iterations?: number; + storedKey?: string; + serverKey?: string; + }): Promise { + this.ensureRunning(); + await this.bridge.sendCommand('scramCredentialResult', opts); + } + /** Update rate limit configuration at runtime. */ public async configureRateLimits(config: IRateLimitConfig): Promise { this.ensureRunning(); @@ -855,6 +892,14 @@ export class RustSecurityBridge extends EventEmitter { this.bridge.on('management:authRequest', handler); } + /** + * Register a handler for scramCredentialRequest events from the Rust SMTP server. + * The handler must call sendScramCredentialResult() with the correlationId. + */ + public onScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void { + this.bridge.on('management:scramCredentialRequest', handler); + } + /** Remove an emailReceived event handler. */ public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void { this.bridge.off('management:emailReceived', handler); @@ -864,6 +909,11 @@ export class RustSecurityBridge extends EventEmitter { public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void { this.bridge.off('management:authRequest', handler); } + + /** Remove a scramCredentialRequest event handler. */ + public offScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void { + this.bridge.off('management:scramCredentialRequest', handler); + } } // Re-export interfaces for consumers @@ -882,6 +932,7 @@ export type { IEmailData, IEmailReceivedEvent, IAuthRequestEvent, + IScramCredentialRequestEvent, IOutboundEmail, ISmtpSendResult, ISmtpSendOptions,