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
109        let mut policy = if is_author_record {
110            // A record published at the Author Domain uses the "p" tag
111            record.p
112        } else if record.np != record.sp
113            && self.domain_exists(rfc5322_from_domain, cache_ipv4).await == Some(false)
114        {
115            // The Author Domain returns NXDOMAIN, i.e. is a non-existent
116            // subdomain (RFC 8020), so "np" applies
117            record.np
118        } else {
119            // The Author Domain is an existing subdomain, so "sp" applies
120            record.sp
121        };
122
123        // A record without a valid "p" tag is treated as "p=none" when a valid
124        // "rua" tag is present, otherwise DMARC does not apply (Section 4.10.1)
125        if policy == Policy::Unspecified {
126            if record.rua.is_empty() {
127                return DmarcOutput::default().with_domain(rfc5322_from_domain);
128            }
129            policy = Policy::None;
130        }
131
132        // In test mode ("t=y") the stated policy is not applied; enforcement is
133        // dropped by one level (RFC 9989 Section 4.7)
134        if record.t {
135            policy = match policy {
136                Policy::Reject => Policy::Quarantine,
137                Policy::Quarantine => Policy::None,
138                other => other,
139            };
140        }
141        let aspf = record.aspf;
142        let adkim = record.adkim;
143
144        let mut output = DmarcOutput {
145            spf_result: DmarcResult::None,
146            dkim_result: DmarcResult::None,
147            domain: rfc5322_from_domain.to_string(),
148            policy,
149            record: None,
150        };
151
152        let dkim_domains = dkim_output
153            .iter()
154            .filter(|o| o.result == DkimResult::Pass)
155            .filter_map(|o| o.signature.as_ref())
156            .map(|s| s.d.as_str())
157            .chain(
158                dkim2_output
159                    .filter(|o| o.result == Dkim2Result::Pass)
160                    .and_then(|o| {
161                        o.chain
162                            .iter()
163                            .find(|link| link.signature.i == 1 && link.result == Dkim2Result::Pass)
164                            .map(|link| link.signature.d.as_str())
165                    }),
166            )
167            .map(to_a_label)
168            .collect::<Vec<_>>();
169
170        // Cache Organizational Domains resolved during alignment
171        let mut org_memo: Vec<(&str, &str)> = vec![(rfc5322_from_domain, author_org)];
172
173        if spf_output.result == SpfResult::Pass {
174            // Check SPF alignment (Section 4.10.2)
175            let aligned = rfc5321_mail_from_domain == rfc5322_from_domain
176                || (aspf == Alignment::Relaxed
177                    && self
178                        .organizational_domain_of(
179                            rfc5321_mail_from_domain,
180                            cache_txt,
181                            &mut org_memo,
182                        )
183                        .await
184                        == author_org);
185            output.spf_result = if aligned {
186                DmarcResult::Pass
187            } else {
188                DmarcResult::Fail(Error::NotAligned)
189            };
190        }
191
192        // Check DKIM alignment (Section 4.10.2)
193        let has_dkim = !dkim_domains.is_empty();
194        let mut aligned = false;
195        for d in dkim_domains.iter().map(Cow::as_ref) {
196            if d == rfc5322_from_domain
197                || (adkim == Alignment::Relaxed
198                    && self
199                        .organizational_domain_of(d, cache_txt, &mut org_memo)
200                        .await
201                        == author_org)
202            {
203                aligned = true;
204                break;
205            }
206        }
207
208        if has_dkim {
209            output.dkim_result = if aligned {
210                DmarcResult::Pass
211            } else {
212                DmarcResult::Fail(Error::NotAligned)
213            };
214        }
215
216        output.with_record(Arc::clone(record))
217    }
218
219    /// Validates the external report e-mail addresses of a DMARC record
220    pub async fn verify_dmarc_report_address<'x, T: AsRef<str>>(
221        &self,
222        domain: &str,
223        addresses: &'x [T],
224        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
225    ) -> Option<Vec<&'x T>> {
226        let domain = to_a_label(domain);
227        let domain = domain.as_ref();
228        let mut result = Vec::with_capacity(addresses.len());
229        let mut key = String::new();
230        for address in addresses {
231            let address_ref = address.as_ref();
232            let address_domain = to_a_label(
233                address_ref
234                    .rsplit_once('@')
235                    .map(|(_, d)| d)
236                    .unwrap_or_default(),
237            );
238            let address_domain = address_domain.as_ref();
239            // No external authorization is required when the destination is the
240            // policy domain itself or a subdomain of it.
241            let is_internal = address_domain == domain
242                || address_domain
243                    .strip_suffix(domain)
244                    .is_some_and(|prefix| prefix.ends_with('.'));
245            let is_authorized = is_internal || {
246                key.clear();
247                key.reserve(domain.len() + REPORT_PREFIX.len() + address_domain.len() + 1);
248                key.push_str(domain);
249                key.push_str(REPORT_PREFIX);
250                key.push_str(address_domain);
251                key.push('.');
252                match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
253                    Ok(_) => true,
254                    Err(Error::Dns(DnsError::Resolver(_))) => return None,
255                    _ => false,
256                }
257            };
258            if is_authorized {
259                result.push(address);
260            }
261        }
262
263        result.into()
264    }
265
266    /// Performs a DNS Tree Walk (RFC 9989 Section 4.10) starting at `domain`
267    /// and returns every valid DMARC Policy Record found from the starting
268    /// point (longest name) up to the top-level domain (shortest name).
269    async fn dmarc_tree_walk<'x>(
270        &self,
271        domain: &'x str,
272        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
273    ) -> crate::Result<Vec<(&'x str, Arc<Dmarc>)>> {
274        let total = domain.split('.').filter(|l| !l.is_empty()).count();
275        let mut found = Vec::new();
276        if total < 2 {
277            return Ok(found);
278        }
279
280        // The first query targets the starting point; subsequent queries drop
281        // to 7 labels when the name has 8 or more (the eight-query cap) and then
282        // one label at a time down to the top-level domain.
283        let mut count = total;
284        let mut key = String::with_capacity(domain.len() + DMARC_PREFIX.len() + 1);
285        loop {
286            let name = drop_leftmost_labels(domain, total - count);
287            key.clear();
288            key.push_str(DMARC_PREFIX);
289            key.push_str(name);
290            key.push('.');
291            match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
292                Ok(dmarc) => {
293                    // A record carrying "psd=y" or "psd=n" stops the walk
294                    let stop = matches!(dmarc.psd, Psd::Yes | Psd::No);
295                    found.push((name, dmarc));
296                    if stop {
297                        break;
298                    }
299                }
300                Err(Error::Dns(DnsError::RecordNotFound(_)))
301                | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
302                Err(err) => return Err(err),
303            }
304
305            if count == 1 {
306                break;
307            }
308            count = if count >= 8 { 7 } else { count - 1 };
309        }
310
311        Ok(found)
312    }
313
314    /// Determines whether `domain` exists in the DNS per RFC 8020.
315    async fn domain_exists(
316        &self,
317        domain: &str,
318        cache_ipv4: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv4Addr>>>,
319    ) -> Option<bool> {
320        match self.ipv4_lookup(domain, cache_ipv4).await {
321            // The name resolves to an address: it exists.
322            Ok(_) => Some(true),
323            // NODATA (any RCODE other than NXDOMAIN) means the name exists but
324            // has no A record; only NXDOMAIN means the name does not exist.
325            Err(Error::Dns(DnsError::RecordNotFound(code))) => {
326                Some(code != crate::DNS_RCODE_NXDOMAIN)
327            }
328            Err(_) => None,
329        }
330    }
331
332    /// Determines the Organizational Domain of `domain` via a DNS Tree Walk (RFC 9989 Section 4.10.2).
333    async fn organizational_domain_of<'x>(
334        &self,
335        domain: &'x str,
336        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
337        memo: &mut Vec<(&'x str, &'x str)>,
338    ) -> &'x str {
339        if let Some(&(_, org)) = memo.iter().find(|(d, _)| *d == domain) {
340            return org;
341        }
342        let org = match self.dmarc_tree_walk(domain, txt_cache).await {
343            Ok(walk) => organizational_domain(&walk, domain).unwrap_or(domain),
344            Err(_) => domain,
345        };
346        memo.push((domain, org));
347        org
348    }
349}
350
351/// Selects the Organizational Domain from the set of DMARC Policy Records
352/// retrieved by a Tree Walk (RFC 9989 Section 4.10.2). The `walk` is ordered
353/// from the longest name (the starting domain) to the shortest.
354fn organizational_domain<'x>(walk: &[(&'x str, Arc<Dmarc>)], start: &'x str) -> Option<&'x str> {
355    for (name, record) in walk {
356        match record.psd {
357            Psd::No => return Some(name),
358            Psd::Yes if *name != start => return Some(one_label_below(name, start)),
359            _ => {}
360        }
361    }
362    walk.last().map(|(name, _)| *name)
363}
364
365/// Returns the domain one label below `psd_name` on the path toward `start`.
366fn one_label_below<'x>(psd_name: &str, start: &'x str) -> &'x str {
367    let depth = psd_name.split('.').filter(|l| !l.is_empty()).count() + 1;
368    let start_labels = start.split('.').filter(|l| !l.is_empty()).count();
369    drop_leftmost_labels(start, start_labels.saturating_sub(depth))
370}
371
372/// Returns the suffix of `domain` after removing its `n` leftmost labels.
373fn drop_leftmost_labels(domain: &str, n: usize) -> &str {
374    let mut suffix = domain;
375    for _ in 0..n {
376        match suffix.split_once('.') {
377            Some((_, rest)) => suffix = rest,
378            None => return "",
379        }
380    }
381    suffix
382}
383
384impl<'x> DmarcParameters<'x> {
385    pub fn new(
386        message: &'x AuthenticatedMessage<'x>,
387        dkim_output: &'x [DkimOutput<'x>],
388        rfc5321_mail_from_domain: &'x str,
389        spf_output: &'x SpfOutput,
390    ) -> Self {
391        Self {
392            message,
393            dkim_output,
394            dkim2_output: None,
395            rfc5321_mail_from_domain,
396            spf_output,
397        }
398    }
399
400    pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
401        self.dkim2_output = Some(dkim2_output);
402        self
403    }
404}
405
406impl<'x> From<DmarcParameters<'x>>
407    for Parameters<
408        'x,
409        DmarcParameters<'x>,
410        NoCache<Box<str>, Txt>,
411        NoCache<Box<str>, RecordSet<MX>>,
412        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
413        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
414        NoCache<IpAddr, RecordSet<Box<str>>>,
415    >
416{
417    fn from(params: DmarcParameters<'x>) -> Self {
418        Parameters::new(params)
419    }
420}
421
422#[cfg(test)]
423#[allow(unused)]
424mod test {
425    use super::DmarcParameters;
426    use crate::{
427        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, Error, MessageAuthenticator,
428        SpfOutput, SpfResult,
429        common::{cache::test::DummyCaches, parse::TxtRecordParser},
430        dkim::{DkimError, Signature},
431        dmarc::{Dmarc, Policy, URI},
432    };
433    use mail_parser::MessageParser;
434    use std::time::{Duration, Instant};
435
436    #[tokio::test]
437    async fn dmarc_verify_alignment() {
438        let resolver = MessageAuthenticator::new_system_conf().unwrap();
439        let caches = DummyCaches::new();
440
441        for (
442            dmarc_dns,
443            dmarc,
444            message,
445            rfc5321_mail_from_domain,
446            signature_domain,
447            dkim,
448            spf,
449            expect_dkim,
450            expect_spf,
451            policy,
452        ) in [
453            // Strict - Pass
454            (
455                "_dmarc.example.org.",
456                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
457                "From: hello@example.org\r\n\r\n",
458                "example.org",
459                "example.org",
460                DkimResult::Pass,
461                SpfResult::Pass,
462                DmarcResult::Pass,
463                DmarcResult::Pass,
464                Policy::Reject,
465            ),
466            // Relaxed - Pass on the Organizational Domain
467            (
468                "_dmarc.example.org.",
469                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
470                "From: hello@example.org\r\n\r\n",
471                "subdomain.example.org",
472                "subdomain.example.org",
473                DkimResult::Pass,
474                SpfResult::Pass,
475                DmarcResult::Pass,
476                DmarcResult::Pass,
477                Policy::Reject,
478            ),
479            // Strict - Fail (subdomain identifiers do not match exactly)
480            (
481                "_dmarc.example.org.",
482                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
483                "From: hello@example.org\r\n\r\n",
484                "subdomain.example.org",
485                "subdomain.example.org",
486                DkimResult::Pass,
487                SpfResult::Pass,
488                DmarcResult::Fail(Error::NotAligned),
489                DmarcResult::Fail(Error::NotAligned),
490                Policy::Reject,
491            ),
492            // Strict - Pass on a U-label From against A-label identifiers
493            (
494                "_dmarc.xn--eebajf.xn--9dbq2a.",
495                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
496                "From: hello@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\r\n\r\n",
497                "xn--eebajf.xn--9dbq2a",
498                "xn--eebajf.xn--9dbq2a",
499                DkimResult::Pass,
500                SpfResult::Pass,
501                DmarcResult::Pass,
502                DmarcResult::Pass,
503                Policy::Reject,
504            ),
505            // Strict - Pass on an A-label From against U-label identifiers
506            (
507                "_dmarc.xn--eebajf.xn--9dbq2a.",
508                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
509                "From: hello@xn--eebajf.xn--9dbq2a\r\n\r\n",
510                "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
511                "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
512                DkimResult::Pass,
513                SpfResult::Pass,
514                DmarcResult::Pass,
515                DmarcResult::Pass,
516                Policy::Reject,
517            ),
518            // Failed mechanisms produce no aligned result
519            (
520                "_dmarc.example.org.",
521                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
522                "From: hello@example.org\r\n\r\n",
523                "example.org",
524                "example.org",
525                DkimResult::Fail(Error::Dkim(DkimError::SignatureExpired)),
526                SpfResult::Fail,
527                DmarcResult::None,
528                DmarcResult::None,
529                Policy::Reject,
530            ),
531        ] {
532            caches.txt_add(
533                dmarc_dns,
534                Dmarc::parse(dmarc.as_bytes()).unwrap(),
535                Instant::now() + Duration::new(3200, 0),
536            );
537
538            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
539            let signature = Signature {
540                d: signature_domain.into(),
541                ..Default::default()
542            };
543            let dkim = DkimOutput {
544                result: dkim,
545                signature: (&signature).into(),
546                report: None,
547                is_atps: false,
548            };
549            let spf = SpfOutput {
550                result: spf,
551                domain: rfc5321_mail_from_domain.to_string(),
552                report: None,
553                explanation: None,
554            };
555            let result = resolver
556                .verify_dmarc(caches.parameters(DmarcParameters::new(
557                    &auth_message,
558                    &[dkim],
559                    rfc5321_mail_from_domain,
560                    &spf,
561                )))
562                .await;
563            assert_eq!(result.dkim_result, expect_dkim, "dkim {message}");
564            assert_eq!(result.spf_result, expect_spf, "spf {message}");
565            assert_eq!(result.policy, policy, "policy {message}");
566        }
567    }
568
569    #[tokio::test]
570    async fn dmarc_policy_discovery() {
571        let resolver = MessageAuthenticator::new_system_conf().unwrap();
572        let expires = Instant::now() + Duration::new(3200, 0);
573
574        // Author Domain has its own record -> "p" applies
575        let caches = DummyCaches::new();
576        caches.txt_add(
577            "_dmarc.example.org.",
578            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
579            expires,
580        );
581        assert_eq!(
582            policy_of(&resolver, &caches, "hello@example.org").await,
583            Policy::Reject,
584        );
585
586        // Existing subdomain, only the Organizational Domain publishes a
587        // record -> "sp" applies
588        let caches = DummyCaches::new();
589        caches.txt_add(
590            "_dmarc.example.org.",
591            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
592            expires,
593        );
594        caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
595        assert_eq!(
596            policy_of(&resolver, &caches, "hello@sub.example.org").await,
597            Policy::Quarantine,
598        );
599
600        // Non-existent subdomain -> "np" applies
601        let caches = DummyCaches::new();
602        caches.txt_add(
603            "_dmarc.example.org.",
604            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
605            expires,
606        );
607        assert_eq!(
608            policy_of(&resolver, &caches, "hello@ghost.example.org").await,
609            Policy::None,
610        );
611
612        // Missing "p" with a valid "rua" -> treated as "p=none"
613        let caches = DummyCaches::new();
614        caches.txt_add(
615            "_dmarc.example.org.",
616            Dmarc::parse(b"v=DMARC1; rua=mailto:d@example.org").unwrap(),
617            expires,
618        );
619        assert_eq!(
620            policy_of(&resolver, &caches, "hello@example.org").await,
621            Policy::None,
622        );
623
624        // No record at all -> DMARC does not apply
625        let caches = DummyCaches::new();
626        let result = verify(&resolver, &caches, "hello@nothing.example").await;
627        assert_eq!(result.dmarc_record(), None);
628    }
629
630    #[tokio::test]
631    async fn dmarc_tree_walk_psd() {
632        let resolver = MessageAuthenticator::new_system_conf().unwrap();
633        let expires = Instant::now() + Duration::new(3200, 0);
634
635        // "psd=n" marks the Organizational Domain: relaxed alignment between
636        // "a.mail.example.com" and an identifier under "mail.example.com" holds
637        let caches = DummyCaches::new();
638        caches.txt_add(
639            "_dmarc.mail.example.com.",
640            Dmarc::parse(b"v=DMARC1; p=reject; psd=n; rua=mailto:d@example.com").unwrap(),
641            expires,
642        );
643        caches.ipv4_add("a.mail.example.com.", vec![[127, 0, 0, 1].into()], expires);
644        let result = verify_aligned(
645            &resolver,
646            &caches,
647            "hello@a.mail.example.com",
648            "b.mail.example.com",
649        )
650        .await;
651        assert_eq!(result.spf_result(), &DmarcResult::Pass);
652
653        // "psd=y" pushes the Organizational Domain one label below, so
654        // "giant.bank.example" and "mega.bank.example" are different
655        // Organizational Domains and do not align
656        let caches = DummyCaches::new();
657        caches.txt_add(
658            "_dmarc.bank.example.",
659            Dmarc::parse(b"v=DMARC1; p=reject; psd=y; rua=mailto:d@bank.example").unwrap(),
660            expires,
661        );
662        caches.txt_add(
663            "_dmarc.giant.bank.example.",
664            Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@giant.bank.example").unwrap(),
665            expires,
666        );
667        let result = verify_aligned(
668            &resolver,
669            &caches,
670            "hello@giant.bank.example",
671            "mega.bank.example",
672        )
673        .await;
674        assert_eq!(result.spf_result(), &DmarcResult::Fail(Error::NotAligned));
675    }
676
677    #[tokio::test]
678    async fn dmarc_tree_walk_query_cap() {
679        let resolver = MessageAuthenticator::new_system_conf().unwrap();
680        let expires = Instant::now() + Duration::new(3200, 0);
681
682        // A record published between the Author Domain and the 7-labels-remaining
683        // shortcut is never discovered, but "example.com" is reached within the
684        // eight-query budget.
685        let caches = DummyCaches::new();
686        caches.txt_add(
687            "_dmarc.example.com.",
688            Dmarc::parse(b"v=DMARC1; p=reject; np=none; rua=mailto:d@example.com").unwrap(),
689            expires,
690        );
691        assert_eq!(
692            policy_of(
693                &resolver,
694                &caches,
695                "hello@a.b.c.d.e.f.g.h.i.j.mail.example.com",
696            )
697            .await,
698            Policy::None,
699        );
700    }
701
702    async fn verify(
703        resolver: &MessageAuthenticator,
704        caches: &DummyCaches,
705        from: &str,
706    ) -> DmarcOutputHelper {
707        verify_aligned(resolver, caches, from, "").await
708    }
709
710    async fn verify_aligned(
711        resolver: &MessageAuthenticator,
712        caches: &DummyCaches,
713        from: &str,
714        mail_from_domain: &str,
715    ) -> DmarcOutputHelper {
716        let message = format!("From: {from}\r\n\r\n");
717        let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
718        let spf = SpfOutput {
719            result: SpfResult::Pass,
720            domain: mail_from_domain.to_string(),
721            report: None,
722            explanation: None,
723        };
724        resolver
725            .verify_dmarc(caches.parameters(DmarcParameters::new(
726                &auth_message,
727                &[],
728                mail_from_domain,
729                &spf,
730            )))
731            .await
732    }
733
734    async fn policy_of(
735        resolver: &MessageAuthenticator,
736        caches: &DummyCaches,
737        from: &str,
738    ) -> Policy {
739        verify(resolver, caches, from).await.policy()
740    }
741
742    type DmarcOutputHelper = crate::DmarcOutput;
743
744    #[tokio::test]
745    async fn dmarc_verify_dkim2() {
746        use crate::Dkim2Result;
747        use crate::dkim2::{ChainLink, Dkim2Output, Signature as Dkim2Signature};
748
749        let resolver = MessageAuthenticator::new_system_conf().unwrap();
750        let caches = DummyCaches::new();
751
752        for (dmarc_dns, dmarc, message, signature_domain, dkim2_result, expect_dkim, policy) in [
753            // Strict - Pass
754            (
755                "_dmarc.example.org.",
756                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
757                "From: hello@example.org\r\n\r\n",
758                "example.org",
759                Dkim2Result::Pass,
760                DmarcResult::Pass,
761                Policy::Reject,
762            ),
763            // Relaxed - Pass on the Organizational Domain
764            (
765                "_dmarc.example.org.",
766                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
767                "From: hello@example.org\r\n\r\n",
768                "subdomain.example.org",
769                Dkim2Result::Pass,
770                DmarcResult::Pass,
771                Policy::Reject,
772            ),
773            // Strict - Fail (subdomain does not match exactly)
774            (
775                "_dmarc.example.org.",
776                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
777                "From: hello@example.org\r\n\r\n",
778                "subdomain.example.org",
779                Dkim2Result::Pass,
780                DmarcResult::Fail(Error::NotAligned),
781                Policy::Reject,
782            ),
783            // Chain did not verify - no DKIM alignment
784            (
785                "_dmarc.example.org.",
786                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
787                "From: hello@example.org\r\n\r\n",
788                "example.org",
789                Dkim2Result::Fail(Error::NotAligned),
790                DmarcResult::None,
791                Policy::Reject,
792            ),
793        ] {
794            caches.txt_add(
795                dmarc_dns,
796                Dmarc::parse(dmarc.as_bytes()).unwrap(),
797                Instant::now() + Duration::new(3200, 0),
798            );
799
800            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
801            let signature = Dkim2Signature {
802                i: 1,
803                d: signature_domain.into(),
804                ..Default::default()
805            };
806            let dkim2 = Dkim2Output {
807                result: dkim2_result.clone(),
808                chain: vec![ChainLink {
809                    signature: &signature,
810                    instance: None,
811                    result: dkim2_result,
812                    custody_ok: true,
813                }],
814            };
815            let spf = SpfOutput {
816                result: SpfResult::None,
817                domain: "example.org".to_string(),
818                report: None,
819                explanation: None,
820            };
821            let result = resolver
822                .verify_dmarc(
823                    caches.parameters(
824                        DmarcParameters::new(&auth_message, &[], "example.org", &spf)
825                            .with_dkim2_output(&dkim2),
826                    ),
827                )
828                .await;
829            assert_eq!(result.dkim_result, expect_dkim);
830            assert_eq!(result.policy, policy);
831        }
832    }
833
834    #[tokio::test]
835    async fn dmarc_verify_report_address() {
836        let resolver = MessageAuthenticator::new_system_conf().unwrap();
837        let caches = DummyCaches::new().with_txt(
838            "example.org._report._dmarc.external.org.",
839            Dmarc::parse(b"v=DMARC1").unwrap(),
840            Instant::now() + Duration::new(3200, 0),
841        );
842        let uris = vec![
843            URI::new("dmarc@example.org", 0),
844            URI::new("dmarc@external.org", 0),
845            URI::new("domain@other.org", 0),
846        ];
847
848        assert_eq!(
849            resolver
850                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
851                .await
852                .unwrap(),
853            vec![
854                &URI::new("dmarc@example.org", 0),
855                &URI::new("dmarc@external.org", 0),
856            ]
857        );
858    }
859
860    #[tokio::test]
861    async fn dmarc_verify_report_address_idn() {
862        let resolver = MessageAuthenticator::new_system_conf().unwrap();
863        let caches = DummyCaches::new();
864        let uris = vec![
865            URI::new(
866                "dmarc@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
867                0,
868            ),
869            URI::new("dmarc@sub.xn--eebajf.xn--9dbq2a", 0),
870        ];
871
872        // A U-label reporting address is internal to its A-label policy domain
873        assert_eq!(
874            resolver
875                .verify_dmarc_report_address("xn--eebajf.xn--9dbq2a", &uris, Some(&caches.txt))
876                .await
877                .unwrap(),
878            uris.iter().collect::<Vec<_>>()
879        );
880    }
881
882    #[tokio::test]
883    async fn dmarc_alignment_is_case_insensitive() {
884        let resolver = MessageAuthenticator::new_system_conf().unwrap();
885        let caches = DummyCaches::new();
886        caches.txt_add(
887            "_dmarc.example.org.",
888            Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; rua=mailto:d@example.org").unwrap(),
889            Instant::now() + Duration::new(3200, 0),
890        );
891
892        let result = verify_aligned(&resolver, &caches, "hello@example.org", "EXAMPLE.ORG").await;
893        assert_eq!(result.spf_result(), &DmarcResult::Pass);
894    }
895}