feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
This commit is contained in:
@@ -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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DnsblResult> {
|
||||
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::<Vec<_>>()
|
||||
.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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user