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