From 0996ded20635910605743ffd96123356075eed4c Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 9 Aug 2024 10:39:53 +0200 Subject: [PATCH] Use public suffix list for DMARC relaxed alignment verification (fixes #37) --- Cargo.toml | 5 +- README.md | 1 + examples/dmarc_verify.rs | 1 + src/dmarc/verify.rs | 77 ++++++++---- src/lib.rs | 249 +-------------------------------------- 5 files changed, 59 insertions(+), 274 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fdb69c1..b21deeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "mail-auth" description = "DKIM, ARC, SPF and DMARC library for Rust" -version = "0.4.3" +version = "0.5.0" edition = "2021" authors = [ "Stalwart Labs "] license = "Apache-2.0 OR MIT" @@ -29,7 +29,7 @@ lru-cache = "0.1.2" mail-parser = { version = "0.9", features = ["ludicrous_mode", "full_encoding"] } mail-builder = { version = "0.3", features = ["ludicrous_mode"] } parking_lot = "0.12.0" -quick-xml = "0.34" +quick-xml = "0.36" ring = { version = "0.17", optional = true } rsa = { version = "0.9.6", optional = true } rustls-pemfile = { version = "2", optional = true } @@ -44,3 +44,4 @@ rand = { version = "0.8.5", optional = true } [dev-dependencies] tokio = { version = "1.16", features = ["net", "io-util", "time", "rt-multi-thread", "macros"] } rustls-pemfile = "2" +psl = "2.1.55" diff --git a/README.md b/README.md index 28087f5..23b1d9a 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ Features: &dkim_result, "example.org", &spf_result, + |domain| psl::domain_str(domain).unwrap_or(domain), ) .await; assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass); diff --git a/examples/dmarc_verify.rs b/examples/dmarc_verify.rs index 85815b1..c172851 100644 --- a/examples/dmarc_verify.rs +++ b/examples/dmarc_verify.rs @@ -63,6 +63,7 @@ async fn main() { &dkim_result, "example.org", &spf_result, + |domain| psl::domain_str(domain).unwrap_or(domain), ) .await; assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass); diff --git a/src/dmarc/verify.rs b/src/dmarc/verify.rs index 630947c..6377716 100644 --- a/src/dmarc/verify.rs +++ b/src/dmarc/verify.rs @@ -18,39 +18,40 @@ use crate::{ use super::{Alignment, Dmarc, URI}; impl Resolver { - /// Verifies the DMARC policy of an RFC5322.From domain + /// Verifies the DMARC policy of an RFC5321.MailFrom domain pub async fn verify_dmarc( &self, message: &AuthenticatedMessage<'_>, dkim_output: &[DkimOutput<'_>], - mail_from_domain: &str, + rfc5321_mail_from_domain: &str, spf_output: &SpfOutput, + domain_suffix_fn: impl Fn(&str) -> &str, ) -> DmarcOutput { - // Extract RFC5322.From - let mut from_domain = ""; + // Extract RFC5322.From domain + let mut rfc5322_from_domain = ""; for from in &message.from { if let Some((_, domain)) = from.rsplit_once('@') { - if from_domain.is_empty() { - from_domain = domain; - } else if from_domain != domain { + if rfc5322_from_domain.is_empty() { + rfc5322_from_domain = domain; + } else if rfc5322_from_domain != domain { // Multi-valued RFC5322.From header fields with multiple // domains MUST be exempt from DMARC checking. return DmarcOutput::default(); } } } - if from_domain.is_empty() { + if rfc5322_from_domain.is_empty() { return DmarcOutput::default(); } // Obtain DMARC policy - let dmarc = match self.dmarc_tree_walk(from_domain).await { + let dmarc = match self.dmarc_tree_walk(rfc5322_from_domain).await { Ok(Some(dmarc)) => dmarc, - Ok(None) => return DmarcOutput::default().with_domain(from_domain), + Ok(None) => return DmarcOutput::default().with_domain(rfc5322_from_domain), Err(err) => { let err = DmarcResult::from(err); return DmarcOutput::default() - .with_domain(from_domain) + .with_domain(rfc5322_from_domain) .with_dkim_result(err.clone()) .with_spf_result(err); } @@ -59,7 +60,7 @@ impl Resolver { let mut output = DmarcOutput { spf_result: DmarcResult::None, dkim_result: DmarcResult::None, - domain: from_domain.to_string(), + domain: rfc5322_from_domain.to_string(), policy: dmarc.p, record: None, }; @@ -67,13 +68,14 @@ impl Resolver { let has_dkim_pass = dkim_output.iter().any(|o| o.result == DkimResult::Pass); if spf_output.result == SpfResult::Pass || has_dkim_pass { // Check SPF alignment - let from_subdomain = format!(".{from_domain}"); + let from_subdomain = format!(".{}", domain_suffix_fn(rfc5322_from_domain)); if spf_output.result == SpfResult::Pass { - output.spf_result = if mail_from_domain == from_domain { + output.spf_result = if rfc5321_mail_from_domain == rfc5322_from_domain { DmarcResult::Pass } else if dmarc.aspf == Alignment::Relaxed - && mail_from_domain.ends_with(&from_subdomain) - || from_domain.ends_with(&format!(".{mail_from_domain}")) + && rfc5321_mail_from_domain.ends_with(&from_subdomain) + || rfc5322_from_domain + .ends_with(&format!(".{}", domain_suffix_fn(rfc5321_mail_from_domain))) { output.policy = dmarc.sp; DmarcResult::Pass @@ -85,15 +87,18 @@ impl Resolver { // Check DKIM alignment if has_dkim_pass { output.dkim_result = if dkim_output.iter().any(|o| { - o.result == DkimResult::Pass && o.signature.as_ref().unwrap().d.eq(from_domain) + o.result == DkimResult::Pass + && o.signature.as_ref().unwrap().d.eq(rfc5322_from_domain) }) { DmarcResult::Pass } else if dmarc.adkim == Alignment::Relaxed && dkim_output.iter().any(|o| { o.result == DkimResult::Pass && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain) - || from_domain - .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d))) + || rfc5322_from_domain.ends_with(&format!( + ".{}", + domain_suffix_fn(&o.signature.as_ref().unwrap().d) + ))) }) { output.policy = dmarc.sp; @@ -102,8 +107,10 @@ impl Resolver { if dkim_output.iter().any(|o| { o.result == DkimResult::Pass && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain) - || from_domain - .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d))) + || rfc5322_from_domain.ends_with(&format!( + ".{}", + domain_suffix_fn(&o.signature.as_ref().unwrap().d) + ))) }) { output.policy = dmarc.sp; } @@ -208,7 +215,7 @@ mod test { dmarc_dns, dmarc, message, - mail_from_domain, + rfc5321_mail_from_domain, signature_domain, dkim, spf, @@ -296,6 +303,22 @@ mod test { DmarcResult::Pass, Policy::Quarantine, ), + // Relaxed - Pass with tree walk and different subdomains + ( + "_dmarc.c.example.org.", + concat!( + "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;", + "rua=mailto:dmarc-feedback@example.org" + ), + "From: hello@a.b.c.example.org\r\n\r\n", + "z.example.org", + "z.example.org", + DkimResult::Pass, + SpfResult::Pass, + DmarcResult::Pass, + DmarcResult::Pass, + Policy::Quarantine, + ), // Failed mechanisms ( "_dmarc.example.org.", @@ -333,12 +356,18 @@ mod test { }; let spf = SpfOutput { result: spf, - domain: mail_from_domain.to_string(), + domain: rfc5321_mail_from_domain.to_string(), report: None, explanation: None, }; let result = resolver - .verify_dmarc(&auth_message, &[dkim], mail_from_domain, &spf) + .verify_dmarc( + &auth_message, + &[dkim], + rfc5321_mail_from_domain, + &spf, + |d| psl::domain_str(d).unwrap_or(d), + ) .await; assert_eq!(result.dkim_result, expect_dkim); assert_eq!(result.spf_result, expect_spf); diff --git a/src/lib.rs b/src/lib.rs index 56f6759..67ad4b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,254 +8,7 @@ * except according to those terms. */ -//! # mail-auth -//! -//! [![crates.io](https://img.shields.io/crates/v/mail-auth)](https://crates.io/crates/mail-auth) -//! [![build](https://github.com/stalwartlabs/mail-auth/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/mail-auth/actions/workflows/rust.yml) -//! [![docs.rs](https://img.shields.io/docsrs/mail-auth)](https://docs.rs/mail-auth) -//! [![crates.io](https://img.shields.io/crates/l/mail-auth)](http://www.apache.org/licenses/LICENSE-2.0) -//! -//! _mail-auth_ is an e-mail authentication and reporting library written in Rust that supports the **DKIM**, **ARC**, **SPF** and **DMARC** -//! protocols. The library aims to be fast, safe and correct while supporting all major [message authentication and reporting RFCs](#conformed-rfcs). -//! -//! Features: -//! -//! - **DomainKeys Identified Mail (DKIM)**: -//! - ED25519-SHA256 (Edwards-Curve Digital Signature Algorithm), RSA-SHA256 and RSA-SHA1 signing and verification. -//! - DKIM Authorized Third-Party Signatures. -//! - DKIM failure reporting using the Abuse Reporting Format. -//! - **Authenticated Received Chain (ARC)**: -//! - ED25519-SHA256 (Edwards-Curve Digital Signature Algorithm), RSA-SHA256 and RSA-SHA1 chain verification. -//! - ARC sealing. -//! - **Sender Policy Framework (SPF)**: -//! - Policy evaluation. -//! - SPF failure reporting using the Abuse Reporting Format. -//! - **Domain-based Message Authentication, Reporting, and Conformance (DMARC)**: -//! - Policy evaluation. -//! - DMARC aggregate report parsing and generation. -//! - **Abuse Reporting Format (ARF)**: -//! - Abuse and Authentication failure reporting. -//! - Feedback report parsing and generation. -//! - **SMTP TLS Reporting**: -//! - Report parsing and generation. -//! -//! ## Usage examples -//! -//! ### DKIM Signature Verification -//! -//! ```rust -//! // Create a resolver using Cloudflare DNS -//! let resolver = Resolver::new_cloudflare_tls().unwrap(); -//! -//! // Parse message -//! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap(); -//! -//! // Validate signature -//! let result = resolver.verify_dkim(&authenticated_message).await; -//! -//! // Make sure all signatures passed verification -//! assert!(result.iter().all(|s| s.result() == &DkimResult::Pass)); -//! ``` -//! -//! ### DKIM Signing -//! -//! ```rust -//! // Sign an e-mail message using RSA-SHA256 -//! let pk_rsa = RsaKey::::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap(); -//! let signature_rsa = DkimSigner::from_key(pk_rsa) -//! .domain("example.com") -//! .selector("default") -//! .headers(["From", "To", "Subject"]) -//! .sign(RFC5322_MESSAGE.as_bytes()) -//! .unwrap(); -//! -//! // Sign an e-mail message using ED25519-SHA256 -//! let pk_ed = Ed25519Key::from_bytes( -//! &base64_decode(ED25519_PUBLIC_KEY.as_bytes()).unwrap(), -//! &base64_decode(ED25519_PRIVATE_KEY.as_bytes()).unwrap(), -//! ) -//! .unwrap(); -//! -//! let signature_ed = DkimSigner::from_key(pk_ed) -//! .domain("example.com") -//! .selector("default-ed") -//! .headers(["From", "To", "Subject"]) -//! .sign(RFC5322_MESSAGE.as_bytes()) -//! .unwrap(); -//! -//! // Print the message including both signatures to stdout -//! println!( -//! "{}{}{}", -//! signature_rsa.to_header(), -//! signature_ed.to_header(), -//! RFC5322_MESSAGE -//! ); -//! ``` -//! -//! ### ARC Chain Verification -//! -//! ```rust -//! // Create a resolver using Cloudflare DNS -//! let resolver = Resolver::new_cloudflare_tls().unwrap(); -//! -//! // Parse message -//! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap(); -//! -//! // Validate ARC chain -//! let result = resolver.verify_arc(&authenticated_message).await; -//! -//! // Make sure ARC passed verification -//! assert_eq!(result.result(), &DkimResult::Pass); -//! ``` -//! -//! ### ARC Chain Sealing -//! -//! ```rust -//! // Create a resolver using Cloudflare DNS -//! let resolver = Resolver::new_cloudflare_tls().unwrap(); -//! -//! // Parse message to be sealed -//! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap(); -//! -//! // Verify ARC and DKIM signatures -//! let arc_result = resolver.verify_arc(&authenticated_message).await; -//! let dkim_result = resolver.verify_dkim(&authenticated_message).await; -//! -//! // Build Authenticated-Results header -//! let auth_results = AuthenticationResults::new("mx.mydomain.org") -//! .with_dkim_result(&dkim_result, "sender@example.org") -//! .with_arc_result(&arc_result, "127.0.0.1".parse().unwrap()); -//! -//! // Seal message -//! if arc_result.can_be_sealed() { -//! // Seal the e-mail message using RSA-SHA256 -//! let pk_rsa = RsaKey::::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap(); -//! let arc_set = ArcSealer::from_key(pk_rsa) -//! .domain("example.org") -//! .selector("default") -//! .headers(["From", "To", "Subject", "DKIM-Signature"]) -//! .seal(&authenticated_message, &auth_results, &arc_result) -//! .unwrap(); -//! -//! // Print the sealed message to stdout -//! println!("{}{}", arc_set.to_header(), RFC5322_MESSAGE) -//! } else { -//! eprintln!("The message could not be sealed, probably an ARC chain with cv=fail was found.") -//! } -//! ``` -//! -//! ### SPF Policy Evaluation -//! -//! ```rust -//! // Create a resolver using Cloudflare DNS -//! let resolver = Resolver::new_cloudflare_tls().unwrap(); -//! -//! // Verify HELO identity -//! let result = resolver -//! .verify_spf_helo("127.0.0.1".parse().unwrap(), "gmail.com", "my-local-domain.org") -//! .await; -//! assert_eq!(result.result(), SpfResult::Fail); -//! -//! // Verify MAIL-FROM identity -//! let result = resolver -//! .verify_spf_sender("::1".parse().unwrap(), "gmail.com", "my-local-domain.org", "sender@gmail.com") -//! .await; -//! assert_eq!(result.result(), SpfResult::Fail); -//! ``` -//! -//! ### DMARC Policy Evaluation -//! -//! ```rust -//! // Create a resolver using Cloudflare DNS -//! let resolver = Resolver::new_cloudflare_tls().unwrap(); -//! -//! // Verify DKIM signatures -//! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap(); -//! let dkim_result = resolver.verify_dkim(&authenticated_message).await; -//! -//! // Verify SPF MAIL-FROM identity -//! let spf_result = resolver -//! .verify_spf_sender("::1".parse().unwrap(), "example.org", "my-local-domain.org", "sender@example.org") -//! .await; -//! -//! // Verify DMARC -//! let dmarc_result = resolver -//! .verify_dmarc( -//! &authenticated_message, -//! &dkim_result, -//! "example.org", -//! &spf_result, -//! ) -//! .await; -//! assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass); -//! assert_eq!(dmarc_result.spf_result(), &DmarcResult::Pass); -//! ``` -//! -//! More examples available under the [examples](examples) directory. -//! -//! ## Testing & Fuzzing -//! -//! To run the testsuite: -//! -//! ```bash -//! $ cargo test --features test -//! ``` -//! -//! To fuzz the library with `cargo-fuzz`: -//! -//! ```bash -//! $ cargo +nightly fuzz run mail_auth -//! ``` -//! -//! ## Conformed RFCs -//! -//! ### DKIM -//! -//! - [RFC 6376 - DomainKeys Identified Mail (DKIM) Signatures](https://datatracker.ietf.org/doc/html/rfc6376) -//! - [RFC 6541 - DomainKeys Identified Mail (DKIM) Authorized Third-Party Signatures](https://datatracker.ietf.org/doc/html/rfc6541) -//! - [RFC 6651 - Extensions to DomainKeys Identified Mail (DKIM) for Failure Reporting](https://datatracker.ietf.org/doc/html/rfc6651) -//! - [RFC 8032 - Edwards-Curve Digital Signature Algorithm (EdDSA)](https://datatracker.ietf.org/doc/html/rfc8032) -//! - [RFC 4686 - Analysis of Threats Motivating DomainKeys Identified Mail (DKIM)](https://datatracker.ietf.org/doc/html/rfc4686) -//! - [RFC 5016 - Requirements for a DomainKeys Identified Mail (DKIM) Signing Practices Protocol](https://datatracker.ietf.org/doc/html/rfc5016) -//! - [RFC 5585 - DomainKeys Identified Mail (DKIM) Service Overview](https://datatracker.ietf.org/doc/html/rfc5585) -//! - [RFC 5672 - DomainKeys Identified Mail (DKIM) Signatures -- Update](https://datatracker.ietf.org/doc/html/rfc5672) -//! - [RFC 5863 - DomainKeys Identified Mail (DKIM) Development, Deployment, and Operations](https://datatracker.ietf.org/doc/html/rfc5863) -//! - [RFC 6377 - DomainKeys Identified Mail (DKIM) and Mailing Lists](https://datatracker.ietf.org/doc/html/rfc6377) -//! -//! ### SPF -//! - [RFC 7208 - Sender Policy Framework (SPF)](https://datatracker.ietf.org/doc/html/rfc7208) -//! - [RFC 6652 - Sender Policy Framework (SPF) Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6652) -//! -//! ### DMARC -//! - [RFC 7489 - Domain-based Message Authentication, Reporting, and Conformance (DMARC)](https://datatracker.ietf.org/doc/html/rfc7489) -//! - [RFC 8617 - The Authenticated Received Chain (ARC) Protocol](https://datatracker.ietf.org/doc/html/rfc8617) -//! - [RFC 8601 - Message Header Field for Indicating Message Authentication Status](https://datatracker.ietf.org/doc/html/rfc8601) -//! - [RFC 8616 - Email Authentication for Internationalized Mail](https://datatracker.ietf.org/doc/html/rfc8616) -//! - [RFC 7960 - Interoperability Issues between Domain-based Message Authentication, Reporting, and Conformance (DMARC) and Indirect Email Flows](https://datatracker.ietf.org/doc/html/rfc7960) -//! -//! ### ARF -//! - [RFC 5965 - An Extensible Format for Email Feedback Reports](https://datatracker.ietf.org/doc/html/rfc5965) -//! - [RFC 6430 - Email Feedback Report Type Value: not-spam](https://datatracker.ietf.org/doc/html/rfc6430) -//! - [RFC 6590 - Redaction of Potentially Sensitive Data from Mail Abuse Reports](https://datatracker.ietf.org/doc/html/rfc6590) -//! - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591) -//! - [RFC 6650 - Creation and Use of Email Feedback Reports: An Applicability Statement for the Abuse Reporting Format (ARF)](https://datatracker.ietf.org/doc/html/rfc6650) -//! -//! ### SMTP TLS Reporting -//! - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460) -//! -//! ## License -//! -//! Licensed under either of -//! -//! * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) -//! * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) -//! -//! at your option. -//! -//! ## Copyright -//! -//! Copyright (C) 2020-2023, Stalwart Labs Ltd. -//! +#![doc = include_str!("../README.md")] use std::{ cell::Cell,