Skip to main content

mail_auth/dmarc/
verify.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{Alignment, Dmarc, Policy, Psd};
8use crate::DnsError;
9use crate::{
10    AuthenticatedMessage, Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error, MX,
11    MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult, Txt,
12    common::cache::NoCache, common::to_a_label, dkim2::Dkim2Output,
13};
14use std::{
15    borrow::Cow,
16    net::{IpAddr, Ipv4Addr, Ipv6Addr},
17    sync::Arc,
18};
19
20const DMARC_PREFIX: &str = "_dmarc.";
21const REPORT_PREFIX: &str = "._report._dmarc.";
22
23pub struct DmarcParameters<'x> {
24    pub message: &'x AuthenticatedMessage<'x>,
25    pub dkim_output: &'x [DkimOutput<'x>],
26    pub dkim2_output: Option<&'x Dkim2Output<'x>>,
27    pub rfc5321_mail_from_domain: &'x str,
28    pub spf_output: &'x SpfOutput,
29}
30
31impl MessageAuthenticator {
32    /// Verifies the DMARC policy of an RFC5321.MailFrom domain
33    pub async fn verify_dmarc<'x, TXT, MXX, IPV4, IPV6, PTR>(
34        &self,
35        params: impl Into<Parameters<'x, DmarcParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
36    ) -> DmarcOutput
37    where
38        TXT: ResolverCache<Box<str>, Txt> + 'x,
39        MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
40        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
41        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
42        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
43    {
44        // Extract RFC5322.From domain
45        let params = params.into();
46        let message = params.params.message;
47        let dkim_output = params.params.dkim_output;
48        let dkim2_output = params.params.dkim2_output;
49        let rfc5321_mail_from_domain = to_a_label(params.params.rfc5321_mail_from_domain);
50        let rfc5321_mail_from_domain = rfc5321_mail_from_domain.as_ref();
51        let spf_output = params.params.spf_output;
52        let cache_txt = params.cache_txt;
53        let cache_ipv4 = params.cache_ipv4;
54        let mut rfc5322_from_domain = Cow::Borrowed("");
55        for from in &message.from {
56            if let Some((_, domain)) = from.rsplit_once('@') {
57                let domain = to_a_label(domain);
58                if rfc5322_from_domain.is_empty() {
59                    rfc5322_from_domain = domain;
60                } else if rfc5322_from_domain != domain {
61                    // Multi-valued RFC5322.From header fields with multiple
62                    // domains MUST be exempt from DMARC checking.
63                    return DmarcOutput::default();
64                }
65            }
66        }
67        if rfc5322_from_domain.is_empty() {
68            return DmarcOutput::default();
69        }
70        let rfc5322_from_domain = rfc5322_from_domain.as_ref();
71
72        // Perform a DNS Tree Walk to discover the DMARC Policy Record for the
73        // Author Domain (RFC 9989 Section 4.10.1)
74        let walk = match self.dmarc_tree_walk(rfc5322_from_domain, cache_txt).await {
75            Ok(walk) => walk,
76            Err(err) => {
77                let err = DmarcResult::from(err);
78                return DmarcOutput::default()
79                    .with_domain(rfc5322_from_domain)
80                    .with_dkim_result(err.clone())
81                    .with_spf_result(err);
82            }
83        };
84        if walk.is_empty() {
85            return DmarcOutput::default().with_domain(rfc5322_from_domain);
86        }
87
88        // Determine the Organizational Domain of the Author Domain
89        let author_org =
90            organizational_domain(&walk, rfc5322_from_domain).unwrap_or(rfc5322_from_domain);
91
92        // Select the DMARC Policy Record to apply: the Author Domain's own
93        // record, otherwise the Organizational Domain's, otherwise the PSD's
94        // (RFC 9989 Section 4.10.1).
95        let (record, is_author_record) =
96            if let Some((_, record)) = walk.iter().find(|(name, _)| *name == rfc5322_from_domain) {
97                (record, true)
98            } else if let Some((_, record)) = walk
99                .iter()
100                .find(|(name, _)| *name == author_org)
101                .or_else(|| walk.last())
102            {
103                (record, false)
104            } else {
105                return DmarcOutput::default().with_domain(rfc5322_from_domain);
106            };
107
108        // Determine the Domain Owner Assessment Policy. A record without a
109        // valid "p" tag is treated as "p=none" when a valid "rua" tag is
110        // present, otherwise DMARC does not apply (Section 4.10.1)
111        let mut policy = if record.p == Policy::Unspecified {
112            if record.rua.is_empty() {
113                return DmarcOutput::default().with_domain(rfc5322_from_domain);
114            }
115            Policy::None
116        } else if is_author_record {
117            // A record published at the Author Domain uses the "p" tag
118            record.p
119        } else if record.np != record.sp
120            && self.domain_exists(rfc5322_from_domain, cache_ipv4).await == Some(false)
121        {
122            // The Author Domain returns NXDOMAIN, i.e. is a non-existent
123            // subdomain (RFC 8020), so "np" applies
124            record.np
125        } else {
126            // The Author Domain is an existing subdomain, so "sp" applies
127            record.sp
128        };
129
130        // In test mode ("t=y") the stated policy is not applied; enforcement is
131        // dropped by one level (RFC 9989 Section 4.7)
132        if record.t {
133            policy = match policy {
134                Policy::Reject => Policy::Quarantine,
135                Policy::Quarantine => Policy::None,
136                other => other,
137            };
138        }
139        let aspf = record.aspf;
140        let adkim = record.adkim;
141
142        let mut output = DmarcOutput {
143            spf_result: DmarcResult::None,
144            dkim_result: DmarcResult::None,
145            domain: rfc5322_from_domain.to_string(),
146            policy,
147            record: None,
148        };
149
150        let dkim_signatures = dkim_output
151            .iter()
152            .filter_map(|o| match (&o.result, &o.signature) {
153                (DkimResult::Pass, Some(signature)) => Some((signature.d.as_str(), None)),
154                (DkimResult::TempError(err), Some(signature)) => {
155                    Some((signature.d.as_str(), Some(err)))
156                }
157                _ => None,
158            })
159            .chain(dkim2_output.and_then(|o| {
160                o.chain
161                    .iter()
162                    .find(|link| link.signature.i == 1)
163                    .and_then(|link| match (&o.result, &link.result) {
164                        (Dkim2Result::Pass, Dkim2Result::Pass) => {
165                            Some((link.signature.d.as_str(), None))
166                        }
167                        (Dkim2Result::TempError(_), Dkim2Result::TempError(err)) => {
168                            Some((link.signature.d.as_str(), Some(err)))
169                        }
170                        _ => None,
171                    })
172            }))
173            .map(|(d, temp_error)| (to_a_label(d), temp_error))
174            .collect::<Vec<_>>();
175
176        // Cache Organizational Domains resolved during alignment
177        let mut org_memo: Vec<(&str, &str)> = vec![(rfc5322_from_domain, author_org)];
178
179        // Check SPF alignment (Section 4.10.2). A DNS error on a check whose
180        // identifier aligns means DMARC can neither pass nor fail (Section 5.3.6)
181        if matches!(spf_output.result, SpfResult::Pass | SpfResult::TempError) {
182            let spf_pass = spf_output.result == SpfResult::Pass;
183            output.spf_result = match self
184                .is_aligned(
185                    rfc5321_mail_from_domain,
186                    rfc5322_from_domain,
187                    author_org,
188                    aspf,
189                    cache_txt,
190                    &mut org_memo,
191                )
192                .await
193            {
194                Ok(true) if spf_pass => DmarcResult::Pass,
195                Ok(true) => DmarcResult::TempError(Error::Dns(DnsError::Resolver(String::new()))),
196                Ok(false) if spf_pass => DmarcResult::Fail(Error::NotAligned),
197                Ok(false) => DmarcResult::None,
198                Err(err) => DmarcResult::TempError(err),
199            };
200        }
201
202        // Check DKIM alignment (Section 4.10.2)
203        let mut has_dkim_pass = false;
204        let mut dkim_temp_error = None;
205        for (d, temp_error) in &dkim_signatures {
206            if temp_error.is_some() && dkim_temp_error.is_some() {
207                continue;
208            }
209            has_dkim_pass |= temp_error.is_none();
210            match self
211                .is_aligned(
212                    d.as_ref(),
213                    rfc5322_from_domain,
214                    author_org,
215                    adkim,
216                    cache_txt,
217                    &mut org_memo,
218                )
219                .await
220            {
221                Ok(true) => match temp_error {
222                    None => {
223                        output.dkim_result = DmarcResult::Pass;
224                        return output.with_record(Arc::clone(record));
225                    }
226                    Some(err) => dkim_temp_error = Some((*err).clone()),
227                },
228                Ok(false) => (),
229                Err(err) => {
230                    dkim_temp_error.get_or_insert(err);
231                }
232            }
233        }
234
235        output.dkim_result = if let Some(err) = dkim_temp_error {
236            DmarcResult::TempError(err)
237        } else if has_dkim_pass {
238            DmarcResult::Fail(Error::NotAligned)
239        } else {
240            DmarcResult::None
241        };
242
243        output.with_record(Arc::clone(record))
244    }
245
246    /// Validates the external report e-mail addresses of a DMARC record
247    pub async fn verify_dmarc_report_address<'x, T: AsRef<str>>(
248        &self,
249        domain: &str,
250        addresses: &'x [T],
251        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
252    ) -> Option<Vec<&'x T>> {
253        let domain = to_a_label(domain);
254        let domain = domain.as_ref();
255        let mut result = Vec::with_capacity(addresses.len());
256        let mut key = String::new();
257        for address in addresses {
258            let address_ref = address.as_ref();
259            let address_domain = to_a_label(
260                address_ref
261                    .rsplit_once('@')
262                    .map(|(_, d)| d)
263                    .unwrap_or_default(),
264            );
265            let address_domain = address_domain.as_ref();
266            // No external authorization is required when the destination is the
267            // policy domain itself or a subdomain of it.
268            let is_internal = address_domain == domain
269                || address_domain
270                    .strip_suffix(domain)
271                    .is_some_and(|prefix| prefix.ends_with('.'));
272            let is_authorized = is_internal || {
273                key.clear();
274                key.reserve(domain.len() + REPORT_PREFIX.len() + address_domain.len() + 1);
275                key.push_str(domain);
276                key.push_str(REPORT_PREFIX);
277                key.push_str(address_domain);
278                key.push('.');
279                match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
280                    Ok(_) => true,
281                    Err(Error::Dns(DnsError::Resolver(_))) => return None,
282                    _ => false,
283                }
284            };
285            if is_authorized {
286                result.push(address);
287            }
288        }
289
290        result.into()
291    }
292
293    /// Performs a DNS Tree Walk (RFC 9989 Section 4.10) starting at `domain`
294    /// and returns every valid DMARC Policy Record found from the starting
295    /// point (longest name) up to the top-level domain (shortest name).
296    async fn dmarc_tree_walk<'x>(
297        &self,
298        domain: &'x str,
299        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
300    ) -> crate::Result<Vec<(&'x str, Arc<Dmarc>)>> {
301        let total = domain.split('.').filter(|l| !l.is_empty()).count();
302        let mut found = Vec::new();
303        if total < 2 {
304            return Ok(found);
305        }
306
307        // The first query targets the starting point; subsequent queries drop
308        // to 7 labels when the name has 8 or more (the eight-query cap) and then
309        // one label at a time down to the top-level domain.
310        let mut count = total;
311        let mut key = String::with_capacity(domain.len() + DMARC_PREFIX.len() + 1);
312        loop {
313            let name = drop_leftmost_labels(domain, total - count);
314            key.clear();
315            key.push_str(DMARC_PREFIX);
316            key.push_str(name);
317            key.push('.');
318            match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
319                Ok(dmarc) => {
320                    // A record carrying "psd=y" or "psd=n" stops the walk
321                    let stop = matches!(dmarc.psd, Psd::Yes | Psd::No);
322                    found.push((name, dmarc));
323                    if stop {
324                        break;
325                    }
326                }
327                Err(Error::Dns(DnsError::RecordNotFound(_)))
328                | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
329                Err(err) => return Err(err),
330            }
331
332            if count == 1 {
333                break;
334            }
335            count = if count >= 8 { 7 } else { count - 1 };
336        }
337
338        Ok(found)
339    }
340
341    /// Determines whether `domain` exists in the DNS per RFC 8020.
342    async fn domain_exists(
343        &self,
344        domain: &str,
345        cache_ipv4: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv4Addr>>>,
346    ) -> Option<bool> {
347        match self.ipv4_lookup(domain, cache_ipv4).await {
348            // The name resolves to an address: it exists.
349            Ok(_) => Some(true),
350            // NODATA (any RCODE other than NXDOMAIN) means the name exists but
351            // has no A record; only NXDOMAIN means the name does not exist.
352            Err(Error::Dns(DnsError::RecordNotFound(code))) => {
353                Some(code != crate::DNS_RCODE_NXDOMAIN)
354            }
355            Err(_) => None,
356        }
357    }
358
359    async fn is_aligned<'x>(
360        &self,
361        domain: &'x str,
362        author_domain: &'x str,
363        author_org: &'x str,
364        alignment: Alignment,
365        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
366        memo: &mut Vec<(&'x str, &'x str)>,
367    ) -> crate::Result<bool> {
368        Ok(domain == author_domain
369            || (alignment == Alignment::Relaxed
370                && domain
371                    .strip_suffix(author_org)
372                    .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('.'))
373                && self
374                    .organizational_domain_of(domain, txt_cache, memo)
375                    .await?
376                    == author_org))
377    }
378
379    /// Determines the Organizational Domain of `domain` via a DNS Tree Walk (RFC 9989 Section 4.10.2).
380    async fn organizational_domain_of<'x>(
381        &self,
382        domain: &'x str,
383        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
384        memo: &mut Vec<(&'x str, &'x str)>,
385    ) -> crate::Result<&'x str> {
386        if let Some(&(_, org)) = memo.iter().find(|(d, _)| *d == domain) {
387            return Ok(org);
388        }
389        let org = match self.dmarc_tree_walk(domain, txt_cache).await {
390            Ok(walk) => organizational_domain(&walk, domain).unwrap_or(domain),
391            Err(err @ Error::Dns(DnsError::Resolver(_))) => return Err(err),
392            Err(_) => domain,
393        };
394        memo.push((domain, org));
395        Ok(org)
396    }
397}
398
399/// Selects the Organizational Domain from the set of DMARC Policy Records
400/// retrieved by a Tree Walk (RFC 9989 Section 4.10.2). The `walk` is ordered
401/// from the longest name (the starting domain) to the shortest.
402fn organizational_domain<'x>(walk: &[(&'x str, Arc<Dmarc>)], start: &'x str) -> Option<&'x str> {
403    for (name, record) in walk {
404        match record.psd {
405            Psd::No => return Some(name),
406            Psd::Yes if *name != start => return Some(one_label_below(name, start)),
407            _ => {}
408        }
409    }
410    walk.last().map(|(name, _)| *name)
411}
412
413/// Returns the domain one label below `psd_name` on the path toward `start`.
414fn one_label_below<'x>(psd_name: &str, start: &'x str) -> &'x str {
415    let depth = psd_name.split('.').filter(|l| !l.is_empty()).count() + 1;
416    let start_labels = start.split('.').filter(|l| !l.is_empty()).count();
417    drop_leftmost_labels(start, start_labels.saturating_sub(depth))
418}
419
420/// Returns the suffix of `domain` after removing its `n` leftmost labels.
421fn drop_leftmost_labels(domain: &str, n: usize) -> &str {
422    let mut suffix = domain;
423    for _ in 0..n {
424        match suffix.split_once('.') {
425            Some((_, rest)) => suffix = rest,
426            None => return "",
427        }
428    }
429    suffix
430}
431
432impl<'x> DmarcParameters<'x> {
433    pub fn new(
434        message: &'x AuthenticatedMessage<'x>,
435        dkim_output: &'x [DkimOutput<'x>],
436        rfc5321_mail_from_domain: &'x str,
437        spf_output: &'x SpfOutput,
438    ) -> Self {
439        Self {
440            message,
441            dkim_output,
442            dkim2_output: None,
443            rfc5321_mail_from_domain,
444            spf_output,
445        }
446    }
447
448    pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
449        self.dkim2_output = Some(dkim2_output);
450        self
451    }
452}
453
454impl<'x> From<DmarcParameters<'x>>
455    for Parameters<
456        'x,
457        DmarcParameters<'x>,
458        NoCache<Box<str>, Txt>,
459        NoCache<Box<str>, RecordSet<MX>>,
460        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
461        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
462        NoCache<IpAddr, RecordSet<Box<str>>>,
463    >
464{
465    fn from(params: DmarcParameters<'x>) -> Self {
466        Parameters::new(params)
467    }
468}
469
470#[cfg(test)]
471#[allow(unused)]
472mod test {
473    use super::DmarcParameters;
474    use crate::{
475        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, DnsError, Error,
476        MessageAuthenticator, SpfOutput, SpfResult,
477        common::{cache::test::DummyCaches, parse::TxtRecordParser},
478        dkim::{DkimError, Signature},
479        dmarc::{Dmarc, Policy, URI},
480    };
481    use mail_parser::MessageParser;
482    use std::time::{Duration, Instant};
483
484    #[tokio::test]
485    async fn dmarc_verify_alignment() {
486        let resolver = MessageAuthenticator::new_system_conf().unwrap();
487        let caches = DummyCaches::new();
488
489        for (
490            dmarc_dns,
491            dmarc,
492            message,
493            rfc5321_mail_from_domain,
494            signature_domain,
495            dkim,
496            spf,
497            expect_dkim,
498            expect_spf,
499            policy,
500        ) in [
501            // Strict - Pass
502            (
503                "_dmarc.example.org.",
504                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
505                "From: hello@example.org\r\n\r\n",
506                "example.org",
507                "example.org",
508                DkimResult::Pass,
509                SpfResult::Pass,
510                DmarcResult::Pass,
511                DmarcResult::Pass,
512                Policy::Reject,
513            ),
514            // Relaxed - Pass on the Organizational Domain
515            (
516                "_dmarc.example.org.",
517                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
518                "From: hello@example.org\r\n\r\n",
519                "subdomain.example.org",
520                "subdomain.example.org",
521                DkimResult::Pass,
522                SpfResult::Pass,
523                DmarcResult::Pass,
524                DmarcResult::Pass,
525                Policy::Reject,
526            ),
527            // Strict - Fail (subdomain identifiers do not match exactly)
528            (
529                "_dmarc.example.org.",
530                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
531                "From: hello@example.org\r\n\r\n",
532                "subdomain.example.org",
533                "subdomain.example.org",
534                DkimResult::Pass,
535                SpfResult::Pass,
536                DmarcResult::Fail(Error::NotAligned),
537                DmarcResult::Fail(Error::NotAligned),
538                Policy::Reject,
539            ),
540            // Strict - Pass on a U-label From against A-label identifiers
541            (
542                "_dmarc.xn--eebajf.xn--9dbq2a.",
543                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
544                "From: hello@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\r\n\r\n",
545                "xn--eebajf.xn--9dbq2a",
546                "xn--eebajf.xn--9dbq2a",
547                DkimResult::Pass,
548                SpfResult::Pass,
549                DmarcResult::Pass,
550                DmarcResult::Pass,
551                Policy::Reject,
552            ),
553            // Strict - Pass on an A-label From against U-label identifiers
554            (
555                "_dmarc.xn--eebajf.xn--9dbq2a.",
556                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
557                "From: hello@xn--eebajf.xn--9dbq2a\r\n\r\n",
558                "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
559                "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
560                DkimResult::Pass,
561                SpfResult::Pass,
562                DmarcResult::Pass,
563                DmarcResult::Pass,
564                Policy::Reject,
565            ),
566            // Failed mechanisms produce no aligned result
567            (
568                "_dmarc.example.org.",
569                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
570                "From: hello@example.org\r\n\r\n",
571                "example.org",
572                "example.org",
573                DkimResult::Fail(Error::Dkim(DkimError::SignatureExpired)),
574                SpfResult::Fail,
575                DmarcResult::None,
576                DmarcResult::None,
577                Policy::Reject,
578            ),
579        ] {
580            caches.txt_add(
581                dmarc_dns,
582                Dmarc::parse(dmarc.as_bytes()).unwrap(),
583                Instant::now() + Duration::new(3200, 0),
584            );
585
586            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
587            let signature = Signature {
588                d: signature_domain.into(),
589                ..Default::default()
590            };
591            let dkim = DkimOutput {
592                result: dkim,
593                signature: (&signature).into(),
594                report: None,
595                is_atps: false,
596            };
597            let spf = SpfOutput {
598                result: spf,
599                domain: rfc5321_mail_from_domain.to_string(),
600                report: None,
601                explanation: None,
602            };
603            let result = resolver
604                .verify_dmarc(caches.parameters(DmarcParameters::new(
605                    &auth_message,
606                    &[dkim],
607                    rfc5321_mail_from_domain,
608                    &spf,
609                )))
610                .await;
611            assert_eq!(result.dkim_result, expect_dkim, "dkim {message}");
612            assert_eq!(result.spf_result, expect_spf, "spf {message}");
613            assert_eq!(result.policy, policy, "policy {message}");
614            let expect_result =
615                if expect_dkim == DmarcResult::Pass || expect_spf == DmarcResult::Pass {
616                    DmarcResult::Pass
617                } else {
618                    DmarcResult::Fail(Error::NotAligned)
619                };
620            assert_eq!(result.result(), expect_result, "result {message}");
621        }
622    }
623
624    #[tokio::test]
625    async fn dmarc_policy_discovery() {
626        let resolver = MessageAuthenticator::new_system_conf().unwrap();
627        let expires = Instant::now() + Duration::new(3200, 0);
628
629        // Author Domain has its own record -> "p" applies
630        let caches = DummyCaches::new();
631        caches.txt_add(
632            "_dmarc.example.org.",
633            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
634            expires,
635        );
636        assert_eq!(
637            policy_of(&resolver, &caches, "hello@example.org").await,
638            Policy::Reject,
639        );
640
641        // Existing subdomain, only the Organizational Domain publishes a
642        // record -> "sp" applies
643        let caches = DummyCaches::new();
644        caches.txt_add(
645            "_dmarc.example.org.",
646            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
647            expires,
648        );
649        caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
650        assert_eq!(
651            policy_of(&resolver, &caches, "hello@sub.example.org").await,
652            Policy::Quarantine,
653        );
654
655        // Non-existent subdomain -> "np" applies
656        let caches = DummyCaches::new();
657        caches.txt_add(
658            "_dmarc.example.org.",
659            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
660            expires,
661        );
662        assert_eq!(
663            policy_of(&resolver, &caches, "hello@ghost.example.org").await,
664            Policy::None,
665        );
666
667        // Missing "p" with a valid "rua" -> treated as "p=none"
668        let caches = DummyCaches::new();
669        caches.txt_add(
670            "_dmarc.example.org.",
671            Dmarc::parse(b"v=DMARC1; rua=mailto:d@example.org").unwrap(),
672            expires,
673        );
674        assert_eq!(
675            policy_of(&resolver, &caches, "hello@example.org").await,
676            Policy::None,
677        );
678
679        let caches = DummyCaches::new();
680        caches.txt_add(
681            "_dmarc.example.org.",
682            Dmarc::parse(b"v=DMARC1; sp=reject; rua=mailto:d@example.org").unwrap(),
683            expires,
684        );
685        caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
686        assert_eq!(
687            policy_of(&resolver, &caches, "hello@sub.example.org").await,
688            Policy::None,
689        );
690
691        let caches = DummyCaches::new();
692        caches.txt_add(
693            "_dmarc.example.org.",
694            Dmarc::parse(b"v=DMARC1; sp=reject; np=reject").unwrap(),
695            expires,
696        );
697        caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
698        let result = verify(&resolver, &caches, "hello@sub.example.org").await;
699        assert_eq!(result.dmarc_record(), None);
700        assert_eq!(result.result(), DmarcResult::None);
701
702        // No record at all -> DMARC does not apply
703        let caches = DummyCaches::new();
704        let result = verify(&resolver, &caches, "hello@nothing.example").await;
705        assert_eq!(result.dmarc_record(), None);
706        assert_eq!(result.result(), DmarcResult::None);
707    }
708
709    #[tokio::test]
710    async fn dmarc_verify_temp_errors() {
711        let resolver = MessageAuthenticator::new_system_conf().unwrap();
712        let caches = DummyCaches::new();
713        caches.txt_add(
714            "_dmarc.example.org.",
715            Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@example.org; ruf=mailto:f@example.org")
716                .unwrap(),
717            Instant::now() + Duration::new(3200, 0),
718        );
719        let dns_error = Error::Dns(DnsError::Resolver("timeout".to_string()));
720        let empty_dns_error = Error::Dns(DnsError::Resolver(String::new()));
721        let auth_message = AuthenticatedMessage::parse(b"From: hello@example.org\r\n\r\n").unwrap();
722
723        for (mail_from_domain, spf, signature_domain, dkim, expect_spf, expect_dkim, expect) in [
724            (
725                "example.org",
726                SpfResult::TempError,
727                "example.org",
728                DkimResult::Fail(Error::Dkim(DkimError::FailedBodyHashMatch)),
729                DmarcResult::TempError(empty_dns_error.clone()),
730                DmarcResult::None,
731                DmarcResult::TempError(empty_dns_error.clone()),
732            ),
733            (
734                "other.net",
735                SpfResult::TempError,
736                "other.net",
737                DkimResult::TempError(dns_error.clone()),
738                DmarcResult::None,
739                DmarcResult::None,
740                DmarcResult::Fail(Error::NotAligned),
741            ),
742            (
743                "other.net",
744                SpfResult::Fail,
745                "example.org",
746                DkimResult::TempError(dns_error.clone()),
747                DmarcResult::None,
748                DmarcResult::TempError(dns_error.clone()),
749                DmarcResult::TempError(dns_error.clone()),
750            ),
751            (
752                "example.org",
753                SpfResult::TempError,
754                "example.org",
755                DkimResult::Pass,
756                DmarcResult::TempError(empty_dns_error.clone()),
757                DmarcResult::Pass,
758                DmarcResult::Pass,
759            ),
760            (
761                "attacker._dns_error.net",
762                SpfResult::Pass,
763                "attacker._dns_error.net",
764                DkimResult::Pass,
765                DmarcResult::Fail(Error::NotAligned),
766                DmarcResult::Fail(Error::NotAligned),
767                DmarcResult::Fail(Error::NotAligned),
768            ),
769            (
770                "sub._dns_error.example.org",
771                SpfResult::Pass,
772                "other.net",
773                DkimResult::None,
774                DmarcResult::TempError(empty_dns_error.clone()),
775                DmarcResult::None,
776                DmarcResult::TempError(empty_dns_error.clone()),
777            ),
778        ] {
779            let signature = Signature {
780                d: signature_domain.into(),
781                ..Default::default()
782            };
783            let dkim = DkimOutput {
784                result: dkim,
785                signature: (&signature).into(),
786                report: None,
787                is_atps: false,
788            };
789            let spf = SpfOutput {
790                result: spf,
791                domain: mail_from_domain.to_string(),
792                report: None,
793                explanation: None,
794            };
795            let result = resolver
796                .verify_dmarc(caches.parameters(DmarcParameters::new(
797                    &auth_message,
798                    &[dkim],
799                    mail_from_domain,
800                    &spf,
801                )))
802                .await;
803            let case = format!("{mail_from_domain} {signature_domain}");
804            assert_eq!(result.spf_result, expect_spf, "spf {case}");
805            assert_eq!(result.dkim_result, expect_dkim, "dkim {case}");
806            assert_eq!(result.result(), expect, "result {case}");
807            assert_eq!(
808                result.failure_report().is_none(),
809                matches!(expect, DmarcResult::TempError(_) | DmarcResult::Pass),
810                "failure report {case}"
811            );
812        }
813    }
814
815    #[tokio::test]
816    async fn dmarc_tree_walk_psd() {
817        let resolver = MessageAuthenticator::new_system_conf().unwrap();
818        let expires = Instant::now() + Duration::new(3200, 0);
819
820        // "psd=n" marks the Organizational Domain: relaxed alignment between
821        // "a.mail.example.com" and an identifier under "mail.example.com" holds
822        let caches = DummyCaches::new();
823        caches.txt_add(
824            "_dmarc.mail.example.com.",
825            Dmarc::parse(b"v=DMARC1; p=reject; psd=n; rua=mailto:d@example.com").unwrap(),
826            expires,
827        );
828        caches.ipv4_add("a.mail.example.com.", vec![[127, 0, 0, 1].into()], expires);
829        let result = verify_aligned(
830            &resolver,
831            &caches,
832            "hello@a.mail.example.com",
833            "b.mail.example.com",
834        )
835        .await;
836        assert_eq!(result.spf_result(), &DmarcResult::Pass);
837
838        // "psd=y" pushes the Organizational Domain one label below, so
839        // "giant.bank.example" and "mega.bank.example" are different
840        // Organizational Domains and do not align
841        let caches = DummyCaches::new();
842        caches.txt_add(
843            "_dmarc.bank.example.",
844            Dmarc::parse(b"v=DMARC1; p=reject; psd=y; rua=mailto:d@bank.example").unwrap(),
845            expires,
846        );
847        caches.txt_add(
848            "_dmarc.giant.bank.example.",
849            Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@giant.bank.example").unwrap(),
850            expires,
851        );
852        let result = verify_aligned(
853            &resolver,
854            &caches,
855            "hello@giant.bank.example",
856            "mega.bank.example",
857        )
858        .await;
859        assert_eq!(result.spf_result(), &DmarcResult::Fail(Error::NotAligned));
860    }
861
862    #[tokio::test]
863    async fn dmarc_tree_walk_query_cap() {
864        let resolver = MessageAuthenticator::new_system_conf().unwrap();
865        let expires = Instant::now() + Duration::new(3200, 0);
866
867        // A record published between the Author Domain and the 7-labels-remaining
868        // shortcut is never discovered, but "example.com" is reached within the
869        // eight-query budget.
870        let caches = DummyCaches::new();
871        caches.txt_add(
872            "_dmarc.example.com.",
873            Dmarc::parse(b"v=DMARC1; p=reject; np=none; rua=mailto:d@example.com").unwrap(),
874            expires,
875        );
876        assert_eq!(
877            policy_of(
878                &resolver,
879                &caches,
880                "hello@a.b.c.d.e.f.g.h.i.j.mail.example.com",
881            )
882            .await,
883            Policy::None,
884        );
885    }
886
887    async fn verify(
888        resolver: &MessageAuthenticator,
889        caches: &DummyCaches,
890        from: &str,
891    ) -> DmarcOutputHelper {
892        verify_aligned(resolver, caches, from, "").await
893    }
894
895    async fn verify_aligned(
896        resolver: &MessageAuthenticator,
897        caches: &DummyCaches,
898        from: &str,
899        mail_from_domain: &str,
900    ) -> DmarcOutputHelper {
901        let message = format!("From: {from}\r\n\r\n");
902        let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
903        let spf = SpfOutput {
904            result: SpfResult::Pass,
905            domain: mail_from_domain.to_string(),
906            report: None,
907            explanation: None,
908        };
909        resolver
910            .verify_dmarc(caches.parameters(DmarcParameters::new(
911                &auth_message,
912                &[],
913                mail_from_domain,
914                &spf,
915            )))
916            .await
917    }
918
919    async fn policy_of(
920        resolver: &MessageAuthenticator,
921        caches: &DummyCaches,
922        from: &str,
923    ) -> Policy {
924        verify(resolver, caches, from).await.policy()
925    }
926
927    type DmarcOutputHelper = crate::DmarcOutput;
928
929    #[tokio::test]
930    async fn dmarc_verify_dkim2() {
931        use crate::Dkim2Result;
932        use crate::dkim2::{ChainLink, Dkim2Output, Signature as Dkim2Signature};
933
934        let resolver = MessageAuthenticator::new_system_conf().unwrap();
935        let caches = DummyCaches::new();
936
937        for (dmarc_dns, dmarc, message, signature_domain, dkim2_result, expect_dkim, policy) in [
938            // Strict - Pass
939            (
940                "_dmarc.example.org.",
941                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
942                "From: hello@example.org\r\n\r\n",
943                "example.org",
944                Dkim2Result::Pass,
945                DmarcResult::Pass,
946                Policy::Reject,
947            ),
948            // Relaxed - Pass on the Organizational Domain
949            (
950                "_dmarc.example.org.",
951                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
952                "From: hello@example.org\r\n\r\n",
953                "subdomain.example.org",
954                Dkim2Result::Pass,
955                DmarcResult::Pass,
956                Policy::Reject,
957            ),
958            // Strict - Fail (subdomain does not match exactly)
959            (
960                "_dmarc.example.org.",
961                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
962                "From: hello@example.org\r\n\r\n",
963                "subdomain.example.org",
964                Dkim2Result::Pass,
965                DmarcResult::Fail(Error::NotAligned),
966                Policy::Reject,
967            ),
968            // Chain did not verify - no DKIM alignment
969            (
970                "_dmarc.example.org.",
971                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
972                "From: hello@example.org\r\n\r\n",
973                "example.org",
974                Dkim2Result::Fail(Error::NotAligned),
975                DmarcResult::None,
976                Policy::Reject,
977            ),
978        ] {
979            caches.txt_add(
980                dmarc_dns,
981                Dmarc::parse(dmarc.as_bytes()).unwrap(),
982                Instant::now() + Duration::new(3200, 0),
983            );
984
985            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
986            let signature = Dkim2Signature {
987                i: 1,
988                d: signature_domain.into(),
989                ..Default::default()
990            };
991            let dkim2 = Dkim2Output {
992                result: dkim2_result.clone(),
993                chain: vec![ChainLink {
994                    signature: &signature,
995                    instance: None,
996                    result: dkim2_result,
997                    custody_ok: true,
998                }],
999            };
1000            let spf = SpfOutput {
1001                result: SpfResult::None,
1002                domain: "example.org".to_string(),
1003                report: None,
1004                explanation: None,
1005            };
1006            let result = resolver
1007                .verify_dmarc(
1008                    caches.parameters(
1009                        DmarcParameters::new(&auth_message, &[], "example.org", &spf)
1010                            .with_dkim2_output(&dkim2),
1011                    ),
1012                )
1013                .await;
1014            assert_eq!(result.dkim_result, expect_dkim);
1015            assert_eq!(result.policy, policy);
1016        }
1017    }
1018
1019    #[tokio::test]
1020    async fn dmarc_verify_report_address() {
1021        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1022        let caches = DummyCaches::new().with_txt(
1023            "example.org._report._dmarc.external.org.",
1024            Dmarc::parse(b"v=DMARC1").unwrap(),
1025            Instant::now() + Duration::new(3200, 0),
1026        );
1027        let uris = vec![
1028            URI::new("dmarc@example.org", 0),
1029            URI::new("dmarc@external.org", 0),
1030            URI::new("domain@other.org", 0),
1031        ];
1032
1033        assert_eq!(
1034            resolver
1035                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
1036                .await
1037                .unwrap(),
1038            vec![
1039                &URI::new("dmarc@example.org", 0),
1040                &URI::new("dmarc@external.org", 0),
1041            ]
1042        );
1043    }
1044
1045    #[tokio::test]
1046    async fn dmarc_verify_report_address_idn() {
1047        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1048        let caches = DummyCaches::new();
1049        let uris = vec![
1050            URI::new(
1051                "dmarc@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
1052                0,
1053            ),
1054            URI::new("dmarc@sub.xn--eebajf.xn--9dbq2a", 0),
1055        ];
1056
1057        // A U-label reporting address is internal to its A-label policy domain
1058        assert_eq!(
1059            resolver
1060                .verify_dmarc_report_address("xn--eebajf.xn--9dbq2a", &uris, Some(&caches.txt))
1061                .await
1062                .unwrap(),
1063            uris.iter().collect::<Vec<_>>()
1064        );
1065    }
1066
1067    #[tokio::test]
1068    async fn dmarc_alignment_is_case_insensitive() {
1069        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1070        let caches = DummyCaches::new();
1071        caches.txt_add(
1072            "_dmarc.example.org.",
1073            Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; rua=mailto:d@example.org").unwrap(),
1074            Instant::now() + Duration::new(3200, 0),
1075        );
1076
1077        let result = verify_aligned(&resolver, &caches, "hello@example.org", "EXAMPLE.ORG").await;
1078        assert_eq!(result.spf_result(), &DmarcResult::Pass);
1079    }
1080}