Commit
Author: mdecimus [mauro@stalw.art]
Hash: 0996ded20635910605743ffd96123356075eed4c
Timestamp: Fri, 09 Aug 2024 08:39:53 +0000 (2 months ago)

+59 -274 +/-5 browse
Use public suffix list for DMARC relaxed alignment verification (fixes #37)
1diff --git a/Cargo.toml b/Cargo.toml
2index fdb69c1..b21deeb 100644
3--- a/Cargo.toml
4+++ b/Cargo.toml
5 @@ -1,7 +1,7 @@
6 [package]
7 name = "mail-auth"
8 description = "DKIM, ARC, SPF and DMARC library for Rust"
9- version = "0.4.3"
10+ version = "0.5.0"
11 edition = "2021"
12 authors = [ "Stalwart Labs <hello@stalw.art>"]
13 license = "Apache-2.0 OR MIT"
14 @@ -29,7 +29,7 @@ lru-cache = "0.1.2"
15 mail-parser = { version = "0.9", features = ["ludicrous_mode", "full_encoding"] }
16 mail-builder = { version = "0.3", features = ["ludicrous_mode"] }
17 parking_lot = "0.12.0"
18- quick-xml = "0.34"
19+ quick-xml = "0.36"
20 ring = { version = "0.17", optional = true }
21 rsa = { version = "0.9.6", optional = true }
22 rustls-pemfile = { version = "2", optional = true }
23 @@ -44,3 +44,4 @@ rand = { version = "0.8.5", optional = true }
24 [dev-dependencies]
25 tokio = { version = "1.16", features = ["net", "io-util", "time", "rt-multi-thread", "macros"] }
26 rustls-pemfile = "2"
27+ psl = "2.1.55"
28 diff --git a/README.md b/README.md
29index 28087f5..23b1d9a 100644
30--- a/README.md
31+++ b/README.md
32 @@ -175,6 +175,7 @@ Features:
33 &dkim_result,
34 "example.org",
35 &spf_result,
36+ |domain| psl::domain_str(domain).unwrap_or(domain),
37 )
38 .await;
39 assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass);
40 diff --git a/examples/dmarc_verify.rs b/examples/dmarc_verify.rs
41index 85815b1..c172851 100644
42--- a/examples/dmarc_verify.rs
43+++ b/examples/dmarc_verify.rs
44 @@ -63,6 +63,7 @@ async fn main() {
45 &dkim_result,
46 "example.org",
47 &spf_result,
48+ |domain| psl::domain_str(domain).unwrap_or(domain),
49 )
50 .await;
51 assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass);
52 diff --git a/src/dmarc/verify.rs b/src/dmarc/verify.rs
53index 630947c..6377716 100644
54--- a/src/dmarc/verify.rs
55+++ b/src/dmarc/verify.rs
56 @@ -18,39 +18,40 @@ use crate::{
57 use super::{Alignment, Dmarc, URI};
58
59 impl Resolver {
60- /// Verifies the DMARC policy of an RFC5322.From domain
61+ /// Verifies the DMARC policy of an RFC5321.MailFrom domain
62 pub async fn verify_dmarc(
63 &self,
64 message: &AuthenticatedMessage<'_>,
65 dkim_output: &[DkimOutput<'_>],
66- mail_from_domain: &str,
67+ rfc5321_mail_from_domain: &str,
68 spf_output: &SpfOutput,
69+ domain_suffix_fn: impl Fn(&str) -> &str,
70 ) -> DmarcOutput {
71- // Extract RFC5322.From
72- let mut from_domain = "";
73+ // Extract RFC5322.From domain
74+ let mut rfc5322_from_domain = "";
75 for from in &message.from {
76 if let Some((_, domain)) = from.rsplit_once('@') {
77- if from_domain.is_empty() {
78- from_domain = domain;
79- } else if from_domain != domain {
80+ if rfc5322_from_domain.is_empty() {
81+ rfc5322_from_domain = domain;
82+ } else if rfc5322_from_domain != domain {
83 // Multi-valued RFC5322.From header fields with multiple
84 // domains MUST be exempt from DMARC checking.
85 return DmarcOutput::default();
86 }
87 }
88 }
89- if from_domain.is_empty() {
90+ if rfc5322_from_domain.is_empty() {
91 return DmarcOutput::default();
92 }
93
94 // Obtain DMARC policy
95- let dmarc = match self.dmarc_tree_walk(from_domain).await {
96+ let dmarc = match self.dmarc_tree_walk(rfc5322_from_domain).await {
97 Ok(Some(dmarc)) => dmarc,
98- Ok(None) => return DmarcOutput::default().with_domain(from_domain),
99+ Ok(None) => return DmarcOutput::default().with_domain(rfc5322_from_domain),
100 Err(err) => {
101 let err = DmarcResult::from(err);
102 return DmarcOutput::default()
103- .with_domain(from_domain)
104+ .with_domain(rfc5322_from_domain)
105 .with_dkim_result(err.clone())
106 .with_spf_result(err);
107 }
108 @@ -59,7 +60,7 @@ impl Resolver {
109 let mut output = DmarcOutput {
110 spf_result: DmarcResult::None,
111 dkim_result: DmarcResult::None,
112- domain: from_domain.to_string(),
113+ domain: rfc5322_from_domain.to_string(),
114 policy: dmarc.p,
115 record: None,
116 };
117 @@ -67,13 +68,14 @@ impl Resolver {
118 let has_dkim_pass = dkim_output.iter().any(|o| o.result == DkimResult::Pass);
119 if spf_output.result == SpfResult::Pass || has_dkim_pass {
120 // Check SPF alignment
121- let from_subdomain = format!(".{from_domain}");
122+ let from_subdomain = format!(".{}", domain_suffix_fn(rfc5322_from_domain));
123 if spf_output.result == SpfResult::Pass {
124- output.spf_result = if mail_from_domain == from_domain {
125+ output.spf_result = if rfc5321_mail_from_domain == rfc5322_from_domain {
126 DmarcResult::Pass
127 } else if dmarc.aspf == Alignment::Relaxed
128- && mail_from_domain.ends_with(&from_subdomain)
129- || from_domain.ends_with(&format!(".{mail_from_domain}"))
130+ && rfc5321_mail_from_domain.ends_with(&from_subdomain)
131+ || rfc5322_from_domain
132+ .ends_with(&format!(".{}", domain_suffix_fn(rfc5321_mail_from_domain)))
133 {
134 output.policy = dmarc.sp;
135 DmarcResult::Pass
136 @@ -85,15 +87,18 @@ impl Resolver {
137 // Check DKIM alignment
138 if has_dkim_pass {
139 output.dkim_result = if dkim_output.iter().any(|o| {
140- o.result == DkimResult::Pass && o.signature.as_ref().unwrap().d.eq(from_domain)
141+ o.result == DkimResult::Pass
142+ && o.signature.as_ref().unwrap().d.eq(rfc5322_from_domain)
143 }) {
144 DmarcResult::Pass
145 } else if dmarc.adkim == Alignment::Relaxed
146 && dkim_output.iter().any(|o| {
147 o.result == DkimResult::Pass
148 && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain)
149- || from_domain
150- .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d)))
151+ || rfc5322_from_domain.ends_with(&format!(
152+ ".{}",
153+ domain_suffix_fn(&o.signature.as_ref().unwrap().d)
154+ )))
155 })
156 {
157 output.policy = dmarc.sp;
158 @@ -102,8 +107,10 @@ impl Resolver {
159 if dkim_output.iter().any(|o| {
160 o.result == DkimResult::Pass
161 && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain)
162- || from_domain
163- .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d)))
164+ || rfc5322_from_domain.ends_with(&format!(
165+ ".{}",
166+ domain_suffix_fn(&o.signature.as_ref().unwrap().d)
167+ )))
168 }) {
169 output.policy = dmarc.sp;
170 }
171 @@ -208,7 +215,7 @@ mod test {
172 dmarc_dns,
173 dmarc,
174 message,
175- mail_from_domain,
176+ rfc5321_mail_from_domain,
177 signature_domain,
178 dkim,
179 spf,
180 @@ -296,6 +303,22 @@ mod test {
181 DmarcResult::Pass,
182 Policy::Quarantine,
183 ),
184+ // Relaxed - Pass with tree walk and different subdomains
185+ (
186+ "_dmarc.c.example.org.",
187+ concat!(
188+ "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
189+ "rua=mailto:dmarc-feedback@example.org"
190+ ),
191+ "From: hello@a.b.c.example.org\r\n\r\n",
192+ "z.example.org",
193+ "z.example.org",
194+ DkimResult::Pass,
195+ SpfResult::Pass,
196+ DmarcResult::Pass,
197+ DmarcResult::Pass,
198+ Policy::Quarantine,
199+ ),
200 // Failed mechanisms
201 (
202 "_dmarc.example.org.",
203 @@ -333,12 +356,18 @@ mod test {
204 };
205 let spf = SpfOutput {
206 result: spf,
207- domain: mail_from_domain.to_string(),
208+ domain: rfc5321_mail_from_domain.to_string(),
209 report: None,
210 explanation: None,
211 };
212 let result = resolver
213- .verify_dmarc(&auth_message, &[dkim], mail_from_domain, &spf)
214+ .verify_dmarc(
215+ &auth_message,
216+ &[dkim],
217+ rfc5321_mail_from_domain,
218+ &spf,
219+ |d| psl::domain_str(d).unwrap_or(d),
220+ )
221 .await;
222 assert_eq!(result.dkim_result, expect_dkim);
223 assert_eq!(result.spf_result, expect_spf);
224 diff --git a/src/lib.rs b/src/lib.rs
225index 56f6759..67ad4b6 100644
226--- a/src/lib.rs
227+++ b/src/lib.rs
228 @@ -8,254 +8,7 @@
229 * except according to those terms.
230 */
231
232- //! # mail-auth
233- //!
234- //! [![crates.io](https://img.shields.io/crates/v/mail-auth)](https://crates.io/crates/mail-auth)
235- //! [![build](https://github.com/stalwartlabs/mail-auth/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/mail-auth/actions/workflows/rust.yml)
236- //! [![docs.rs](https://img.shields.io/docsrs/mail-auth)](https://docs.rs/mail-auth)
237- //! [![crates.io](https://img.shields.io/crates/l/mail-auth)](http://www.apache.org/licenses/LICENSE-2.0)
238- //!
239- //! _mail-auth_ is an e-mail authentication and reporting library written in Rust that supports the **DKIM**, **ARC**, **SPF** and **DMARC**
240- //! protocols. The library aims to be fast, safe and correct while supporting all major [message authentication and reporting RFCs](#conformed-rfcs).
241- //!
242- //! Features:
243- //!
244- //! - **DomainKeys Identified Mail (DKIM)**:
245- //! - ED25519-SHA256 (Edwards-Curve Digital Signature Algorithm), RSA-SHA256 and RSA-SHA1 signing and verification.
246- //! - DKIM Authorized Third-Party Signatures.
247- //! - DKIM failure reporting using the Abuse Reporting Format.
248- //! - **Authenticated Received Chain (ARC)**:
249- //! - ED25519-SHA256 (Edwards-Curve Digital Signature Algorithm), RSA-SHA256 and RSA-SHA1 chain verification.
250- //! - ARC sealing.
251- //! - **Sender Policy Framework (SPF)**:
252- //! - Policy evaluation.
253- //! - SPF failure reporting using the Abuse Reporting Format.
254- //! - **Domain-based Message Authentication, Reporting, and Conformance (DMARC)**:
255- //! - Policy evaluation.
256- //! - DMARC aggregate report parsing and generation.
257- //! - **Abuse Reporting Format (ARF)**:
258- //! - Abuse and Authentication failure reporting.
259- //! - Feedback report parsing and generation.
260- //! - **SMTP TLS Reporting**:
261- //! - Report parsing and generation.
262- //!
263- //! ## Usage examples
264- //!
265- //! ### DKIM Signature Verification
266- //!
267- //! ```rust
268- //! // Create a resolver using Cloudflare DNS
269- //! let resolver = Resolver::new_cloudflare_tls().unwrap();
270- //!
271- //! // Parse message
272- //! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap();
273- //!
274- //! // Validate signature
275- //! let result = resolver.verify_dkim(&authenticated_message).await;
276- //!
277- //! // Make sure all signatures passed verification
278- //! assert!(result.iter().all(|s| s.result() == &DkimResult::Pass));
279- //! ```
280- //!
281- //! ### DKIM Signing
282- //!
283- //! ```rust
284- //! // Sign an e-mail message using RSA-SHA256
285- //! let pk_rsa = RsaKey::<Sha256>::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap();
286- //! let signature_rsa = DkimSigner::from_key(pk_rsa)
287- //! .domain("example.com")
288- //! .selector("default")
289- //! .headers(["From", "To", "Subject"])
290- //! .sign(RFC5322_MESSAGE.as_bytes())
291- //! .unwrap();
292- //!
293- //! // Sign an e-mail message using ED25519-SHA256
294- //! let pk_ed = Ed25519Key::from_bytes(
295- //! &base64_decode(ED25519_PUBLIC_KEY.as_bytes()).unwrap(),
296- //! &base64_decode(ED25519_PRIVATE_KEY.as_bytes()).unwrap(),
297- //! )
298- //! .unwrap();
299- //!
300- //! let signature_ed = DkimSigner::from_key(pk_ed)
301- //! .domain("example.com")
302- //! .selector("default-ed")
303- //! .headers(["From", "To", "Subject"])
304- //! .sign(RFC5322_MESSAGE.as_bytes())
305- //! .unwrap();
306- //!
307- //! // Print the message including both signatures to stdout
308- //! println!(
309- //! "{}{}{}",
310- //! signature_rsa.to_header(),
311- //! signature_ed.to_header(),
312- //! RFC5322_MESSAGE
313- //! );
314- //! ```
315- //!
316- //! ### ARC Chain Verification
317- //!
318- //! ```rust
319- //! // Create a resolver using Cloudflare DNS
320- //! let resolver = Resolver::new_cloudflare_tls().unwrap();
321- //!
322- //! // Parse message
323- //! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap();
324- //!
325- //! // Validate ARC chain
326- //! let result = resolver.verify_arc(&authenticated_message).await;
327- //!
328- //! // Make sure ARC passed verification
329- //! assert_eq!(result.result(), &DkimResult::Pass);
330- //! ```
331- //!
332- //! ### ARC Chain Sealing
333- //!
334- //! ```rust
335- //! // Create a resolver using Cloudflare DNS
336- //! let resolver = Resolver::new_cloudflare_tls().unwrap();
337- //!
338- //! // Parse message to be sealed
339- //! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap();
340- //!
341- //! // Verify ARC and DKIM signatures
342- //! let arc_result = resolver.verify_arc(&authenticated_message).await;
343- //! let dkim_result = resolver.verify_dkim(&authenticated_message).await;
344- //!
345- //! // Build Authenticated-Results header
346- //! let auth_results = AuthenticationResults::new("mx.mydomain.org")
347- //! .with_dkim_result(&dkim_result, "sender@example.org")
348- //! .with_arc_result(&arc_result, "127.0.0.1".parse().unwrap());
349- //!
350- //! // Seal message
351- //! if arc_result.can_be_sealed() {
352- //! // Seal the e-mail message using RSA-SHA256
353- //! let pk_rsa = RsaKey::<Sha256>::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap();
354- //! let arc_set = ArcSealer::from_key(pk_rsa)
355- //! .domain("example.org")
356- //! .selector("default")
357- //! .headers(["From", "To", "Subject", "DKIM-Signature"])
358- //! .seal(&authenticated_message, &auth_results, &arc_result)
359- //! .unwrap();
360- //!
361- //! // Print the sealed message to stdout
362- //! println!("{}{}", arc_set.to_header(), RFC5322_MESSAGE)
363- //! } else {
364- //! eprintln!("The message could not be sealed, probably an ARC chain with cv=fail was found.")
365- //! }
366- //! ```
367- //!
368- //! ### SPF Policy Evaluation
369- //!
370- //! ```rust
371- //! // Create a resolver using Cloudflare DNS
372- //! let resolver = Resolver::new_cloudflare_tls().unwrap();
373- //!
374- //! // Verify HELO identity
375- //! let result = resolver
376- //! .verify_spf_helo("127.0.0.1".parse().unwrap(), "gmail.com", "my-local-domain.org")
377- //! .await;
378- //! assert_eq!(result.result(), SpfResult::Fail);
379- //!
380- //! // Verify MAIL-FROM identity
381- //! let result = resolver
382- //! .verify_spf_sender("::1".parse().unwrap(), "gmail.com", "my-local-domain.org", "sender@gmail.com")
383- //! .await;
384- //! assert_eq!(result.result(), SpfResult::Fail);
385- //! ```
386- //!
387- //! ### DMARC Policy Evaluation
388- //!
389- //! ```rust
390- //! // Create a resolver using Cloudflare DNS
391- //! let resolver = Resolver::new_cloudflare_tls().unwrap();
392- //!
393- //! // Verify DKIM signatures
394- //! let authenticated_message = AuthenticatedMessage::parse(RFC5322_MESSAGE.as_bytes()).unwrap();
395- //! let dkim_result = resolver.verify_dkim(&authenticated_message).await;
396- //!
397- //! // Verify SPF MAIL-FROM identity
398- //! let spf_result = resolver
399- //! .verify_spf_sender("::1".parse().unwrap(), "example.org", "my-local-domain.org", "sender@example.org")
400- //! .await;
401- //!
402- //! // Verify DMARC
403- //! let dmarc_result = resolver
404- //! .verify_dmarc(
405- //! &authenticated_message,
406- //! &dkim_result,
407- //! "example.org",
408- //! &spf_result,
409- //! )
410- //! .await;
411- //! assert_eq!(dmarc_result.dkim_result(), &DmarcResult::Pass);
412- //! assert_eq!(dmarc_result.spf_result(), &DmarcResult::Pass);
413- //! ```
414- //!
415- //! More examples available under the [examples](examples) directory.
416- //!
417- //! ## Testing & Fuzzing
418- //!
419- //! To run the testsuite:
420- //!
421- //! ```bash
422- //! $ cargo test --features test
423- //! ```
424- //!
425- //! To fuzz the library with `cargo-fuzz`:
426- //!
427- //! ```bash
428- //! $ cargo +nightly fuzz run mail_auth
429- //! ```
430- //!
431- //! ## Conformed RFCs
432- //!
433- //! ### DKIM
434- //!
435- //! - [RFC 6376 - DomainKeys Identified Mail (DKIM) Signatures](https://datatracker.ietf.org/doc/html/rfc6376)
436- //! - [RFC 6541 - DomainKeys Identified Mail (DKIM) Authorized Third-Party Signatures](https://datatracker.ietf.org/doc/html/rfc6541)
437- //! - [RFC 6651 - Extensions to DomainKeys Identified Mail (DKIM) for Failure Reporting](https://datatracker.ietf.org/doc/html/rfc6651)
438- //! - [RFC 8032 - Edwards-Curve Digital Signature Algorithm (EdDSA)](https://datatracker.ietf.org/doc/html/rfc8032)
439- //! - [RFC 4686 - Analysis of Threats Motivating DomainKeys Identified Mail (DKIM)](https://datatracker.ietf.org/doc/html/rfc4686)
440- //! - [RFC 5016 - Requirements for a DomainKeys Identified Mail (DKIM) Signing Practices Protocol](https://datatracker.ietf.org/doc/html/rfc5016)
441- //! - [RFC 5585 - DomainKeys Identified Mail (DKIM) Service Overview](https://datatracker.ietf.org/doc/html/rfc5585)
442- //! - [RFC 5672 - DomainKeys Identified Mail (DKIM) Signatures -- Update](https://datatracker.ietf.org/doc/html/rfc5672)
443- //! - [RFC 5863 - DomainKeys Identified Mail (DKIM) Development, Deployment, and Operations](https://datatracker.ietf.org/doc/html/rfc5863)
444- //! - [RFC 6377 - DomainKeys Identified Mail (DKIM) and Mailing Lists](https://datatracker.ietf.org/doc/html/rfc6377)
445- //!
446- //! ### SPF
447- //! - [RFC 7208 - Sender Policy Framework (SPF)](https://datatracker.ietf.org/doc/html/rfc7208)
448- //! - [RFC 6652 - Sender Policy Framework (SPF) Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6652)
449- //!
450- //! ### DMARC
451- //! - [RFC 7489 - Domain-based Message Authentication, Reporting, and Conformance (DMARC)](https://datatracker.ietf.org/doc/html/rfc7489)
452- //! - [RFC 8617 - The Authenticated Received Chain (ARC) Protocol](https://datatracker.ietf.org/doc/html/rfc8617)
453- //! - [RFC 8601 - Message Header Field for Indicating Message Authentication Status](https://datatracker.ietf.org/doc/html/rfc8601)
454- //! - [RFC 8616 - Email Authentication for Internationalized Mail](https://datatracker.ietf.org/doc/html/rfc8616)
455- //! - [RFC 7960 - Interoperability Issues between Domain-based Message Authentication, Reporting, and Conformance (DMARC) and Indirect Email Flows](https://datatracker.ietf.org/doc/html/rfc7960)
456- //!
457- //! ### ARF
458- //! - [RFC 5965 - An Extensible Format for Email Feedback Reports](https://datatracker.ietf.org/doc/html/rfc5965)
459- //! - [RFC 6430 - Email Feedback Report Type Value: not-spam](https://datatracker.ietf.org/doc/html/rfc6430)
460- //! - [RFC 6590 - Redaction of Potentially Sensitive Data from Mail Abuse Reports](https://datatracker.ietf.org/doc/html/rfc6590)
461- //! - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591)
462- //! - [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)
463- //!
464- //! ### SMTP TLS Reporting
465- //! - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460)
466- //!
467- //! ## License
468- //!
469- //! Licensed under either of
470- //!
471- //! * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
472- //! * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
473- //!
474- //! at your option.
475- //!
476- //! ## Copyright
477- //!
478- //! Copyright (C) 2020-2023, Stalwart Labs Ltd.
479- //!
480+ #![doc = include_str!("../README.md")]
481
482 use std::{
483 cell::Cell,