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};
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, dkim2::Dkim2Output,
13};
14use std::{
15    net::{IpAddr, Ipv4Addr, Ipv6Addr},
16    sync::Arc,
17};
18
19pub struct DmarcParameters<'x, F>
20where
21    F: for<'y> Fn(&'y str) -> &'y str,
22{
23    pub message: &'x AuthenticatedMessage<'x>,
24    pub dkim_output: &'x [DkimOutput<'x>],
25    pub dkim2_output: Option<&'x Dkim2Output<'x>>,
26    pub rfc5321_mail_from_domain: &'x str,
27    pub spf_output: &'x SpfOutput,
28    pub domain_suffix_fn: F,
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, F>(
34        &self,
35        params: impl Into<Parameters<'x, DmarcParameters<'x, F>, 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        F: for<'y> Fn(&'y str) -> &'y str,
44    {
45        // Extract RFC5322.From domain
46        let params = params.into();
47        let message = params.params.message;
48        let dkim_output = params.params.dkim_output;
49        let dkim2_output = params.params.dkim2_output;
50        let domain_suffix_fn = params.params.domain_suffix_fn;
51        let rfc5321_mail_from_domain = params.params.rfc5321_mail_from_domain;
52        let spf_output = params.params.spf_output;
53        let mut rfc5322_from_domain = "";
54        for from in &message.from {
55            if let Some((_, domain)) = from.rsplit_once('@') {
56                if rfc5322_from_domain.is_empty() {
57                    rfc5322_from_domain = domain;
58                } else if rfc5322_from_domain != domain {
59                    // Multi-valued RFC5322.From header fields with multiple
60                    // domains MUST be exempt from DMARC checking.
61                    return DmarcOutput::default();
62                }
63            }
64        }
65        if rfc5322_from_domain.is_empty() {
66            return DmarcOutput::default();
67        }
68
69        // Obtain DMARC policy
70        let dmarc = match self
71            .dmarc_tree_walk(rfc5322_from_domain, params.cache_txt)
72            .await
73        {
74            Ok(Some(dmarc)) => dmarc,
75            Ok(None) => return DmarcOutput::default().with_domain(rfc5322_from_domain),
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
85        let mut output = DmarcOutput {
86            spf_result: DmarcResult::None,
87            dkim_result: DmarcResult::None,
88            domain: rfc5322_from_domain.to_string(),
89            policy: dmarc.p,
90            record: None,
91        };
92
93        let dkim_pass_domains = dkim_output
94            .iter()
95            .filter(|o| o.result == DkimResult::Pass)
96            .filter_map(|o| o.signature.as_ref())
97            .map(|s| s.d.as_str())
98            .chain(
99                dkim2_output
100                    .filter(|o| o.result == Dkim2Result::Pass)
101                    .and_then(|o| {
102                        o.chain
103                            .iter()
104                            .find(|link| link.signature.i == 1 && link.result == Dkim2Result::Pass)
105                            .map(|link| link.signature.d.as_str())
106                    }),
107            )
108            .collect::<Vec<_>>();
109
110        let has_dkim_pass = !dkim_pass_domains.is_empty();
111        if spf_output.result == SpfResult::Pass || has_dkim_pass {
112            // Check SPF alignment
113            let rfc5322_from_subdomain = domain_suffix_fn(rfc5322_from_domain);
114            if spf_output.result == SpfResult::Pass {
115                output.spf_result = if rfc5321_mail_from_domain == rfc5322_from_domain {
116                    DmarcResult::Pass
117                } else if dmarc.aspf == Alignment::Relaxed
118                    && domain_suffix_fn(rfc5321_mail_from_domain) == rfc5322_from_subdomain
119                {
120                    output.policy = dmarc.sp;
121                    DmarcResult::Pass
122                } else {
123                    DmarcResult::Fail(Error::NotAligned)
124                };
125            }
126
127            // Check DKIM alignment
128            if has_dkim_pass {
129                output.dkim_result = if dkim_pass_domains.iter().any(|d| d.eq(&rfc5322_from_domain))
130                {
131                    DmarcResult::Pass
132                } else if dmarc.adkim == Alignment::Relaxed
133                    && dkim_pass_domains
134                        .iter()
135                        .any(|&d| domain_suffix_fn(d) == rfc5322_from_subdomain)
136                {
137                    output.policy = dmarc.sp;
138                    DmarcResult::Pass
139                } else {
140                    if dkim_pass_domains
141                        .iter()
142                        .any(|&d| domain_suffix_fn(d) == rfc5322_from_subdomain)
143                    {
144                        output.policy = dmarc.sp;
145                    }
146                    DmarcResult::Fail(Error::NotAligned)
147                };
148            }
149        }
150
151        output.with_record(dmarc)
152    }
153
154    /// Validates the external report e-mail addresses of a DMARC record
155    pub async fn verify_dmarc_report_address<'x, T: AsRef<str>>(
156        &self,
157        domain: &str,
158        addresses: &'x [T],
159        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
160    ) -> Option<Vec<&'x T>> {
161        let mut result = Vec::with_capacity(addresses.len());
162        for address in addresses {
163            let address_ref = address.as_ref();
164            if address_ref.ends_with(domain)
165                || match self
166                    .txt_lookup::<Dmarc>(
167                        format!(
168                            "{}._report._dmarc.{}.",
169                            domain,
170                            address_ref
171                                .rsplit_once('@')
172                                .map(|(_, d)| d)
173                                .unwrap_or_default()
174                        ),
175                        txt_cache,
176                    )
177                    .await
178                {
179                    Ok(_) => true,
180                    Err(Error::Dns(DnsError::Resolver(_))) => return None,
181                    _ => false,
182                }
183            {
184                result.push(address);
185            }
186        }
187
188        result.into()
189    }
190
191    async fn dmarc_tree_walk(
192        &self,
193        domain: &str,
194        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
195    ) -> crate::Result<Option<Arc<Dmarc>>> {
196        let labels = domain.split('.').collect::<Vec<_>>();
197        let mut x = labels.len();
198        if x == 1 {
199            return Ok(None);
200        }
201        while x != 0 {
202            // Build query domain
203            let mut domain = String::with_capacity(domain.len() + 8);
204            domain.push_str("_dmarc");
205            for label in labels.iter().skip(labels.len() - x) {
206                domain.push('.');
207                domain.push_str(label);
208            }
209            domain.push('.');
210
211            // Query DMARC
212            match self.txt_lookup::<Dmarc>(domain, txt_cache).await {
213                Ok(dmarc) => {
214                    return Ok(Some(dmarc));
215                }
216                Err(Error::Dns(DnsError::RecordNotFound(_)))
217                | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
218                Err(err) => return Err(err),
219            }
220
221            // If x < 5, remove the left-most (highest-numbered) label from the subject domain.
222            // If x >= 5, remove the left-most (highest-numbered) labels from the subject
223            // domain until 4 labels remain.
224            if x < 5 {
225                x -= 1;
226            } else {
227                x = 4;
228            }
229        }
230
231        Ok(None)
232    }
233}
234
235impl<'x> DmarcParameters<'x, fn(&str) -> &str> {
236    pub fn new(
237        message: &'x AuthenticatedMessage<'x>,
238        dkim_output: &'x [DkimOutput<'x>],
239        rfc5321_mail_from_domain: &'x str,
240        spf_output: &'x SpfOutput,
241    ) -> Self {
242        Self {
243            message,
244            dkim_output,
245            dkim2_output: None,
246            rfc5321_mail_from_domain,
247            spf_output,
248            domain_suffix_fn: |d| d,
249        }
250    }
251}
252
253impl<'x, F> DmarcParameters<'x, F>
254where
255    F: for<'y> Fn(&'y str) -> &'y str,
256{
257    pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
258        self.dkim2_output = Some(dkim2_output);
259        self
260    }
261
262    pub fn with_domain_suffix_fn<NewF>(self, f: NewF) -> DmarcParameters<'x, NewF>
263    where
264        NewF: for<'y> Fn(&'y str) -> &'y str,
265    {
266        DmarcParameters {
267            message: self.message,
268            dkim_output: self.dkim_output,
269            dkim2_output: self.dkim2_output,
270            rfc5321_mail_from_domain: self.rfc5321_mail_from_domain,
271            spf_output: self.spf_output,
272            domain_suffix_fn: f,
273        }
274    }
275}
276
277impl<'x, F> From<DmarcParameters<'x, F>>
278    for Parameters<
279        'x,
280        DmarcParameters<'x, F>,
281        NoCache<Box<str>, Txt>,
282        NoCache<Box<str>, RecordSet<MX>>,
283        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
284        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
285        NoCache<IpAddr, RecordSet<Box<str>>>,
286    >
287where
288    F: for<'y> Fn(&'y str) -> &'y str,
289{
290    fn from(params: DmarcParameters<'x, F>) -> Self {
291        Parameters::new(params)
292    }
293}
294
295#[cfg(test)]
296#[allow(unused)]
297mod test {
298    use super::DmarcParameters;
299    use crate::{
300        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, Error, MessageAuthenticator,
301        SpfOutput, SpfResult,
302        common::{cache::test::DummyCaches, parse::TxtRecordParser},
303        dkim::{DkimError, Signature},
304        dmarc::{Dmarc, Policy, URI},
305    };
306    use mail_parser::MessageParser;
307    use std::time::{Duration, Instant};
308
309    #[tokio::test]
310    async fn dmarc_verify() {
311        let resolver = MessageAuthenticator::new_system_conf().unwrap();
312        let caches = DummyCaches::new();
313
314        for (
315            dmarc_dns,
316            dmarc,
317            message,
318            rfc5321_mail_from_domain,
319            signature_domain,
320            dkim,
321            spf,
322            expect_dkim,
323            expect_spf,
324            policy,
325        ) in [
326            // Strict - Pass
327            (
328                "_dmarc.example.org.",
329                concat!(
330                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
331                    "rua=mailto:dmarc-feedback@example.org"
332                ),
333                "From: hello@example.org\r\n\r\n",
334                "example.org",
335                "example.org",
336                DkimResult::Pass,
337                SpfResult::Pass,
338                DmarcResult::Pass,
339                DmarcResult::Pass,
340                Policy::Reject,
341            ),
342            // Relaxed - Pass
343            (
344                "_dmarc.example.org.",
345                concat!(
346                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
347                    "rua=mailto:dmarc-feedback@example.org"
348                ),
349                "From: hello@example.org\r\n\r\n",
350                "subdomain.example.org",
351                "subdomain.example.org",
352                DkimResult::Pass,
353                SpfResult::Pass,
354                DmarcResult::Pass,
355                DmarcResult::Pass,
356                Policy::Quarantine,
357            ),
358            // Strict - Fail
359            (
360                "_dmarc.example.org.",
361                concat!(
362                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
363                    "rua=mailto:dmarc-feedback@example.org"
364                ),
365                "From: hello@example.org\r\n\r\n",
366                "subdomain.example.org",
367                "subdomain.example.org",
368                DkimResult::Pass,
369                SpfResult::Pass,
370                DmarcResult::Fail(Error::NotAligned),
371                DmarcResult::Fail(Error::NotAligned),
372                Policy::Quarantine,
373            ),
374            // Strict - Pass with tree walk
375            (
376                "_dmarc.example.org.",
377                concat!(
378                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
379                    "rua=mailto:dmarc-feedback@example.org"
380                ),
381                "From: hello@a.b.c.example.org\r\n\r\n",
382                "a.b.c.example.org",
383                "a.b.c.example.org",
384                DkimResult::Pass,
385                SpfResult::Pass,
386                DmarcResult::Pass,
387                DmarcResult::Pass,
388                Policy::Reject,
389            ),
390            // Relaxed - Pass with tree walk
391            (
392                "_dmarc.c.example.org.",
393                concat!(
394                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
395                    "rua=mailto:dmarc-feedback@example.org"
396                ),
397                "From: hello@a.b.c.example.org\r\n\r\n",
398                "example.org",
399                "example.org",
400                DkimResult::Pass,
401                SpfResult::Pass,
402                DmarcResult::Pass,
403                DmarcResult::Pass,
404                Policy::Quarantine,
405            ),
406            // Relaxed - Pass with tree walk and different subdomains
407            (
408                "_dmarc.c.example.org.",
409                concat!(
410                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
411                    "rua=mailto:dmarc-feedback@example.org"
412                ),
413                "From: hello@a.b.c.example.org\r\n\r\n",
414                "z.example.org",
415                "z.example.org",
416                DkimResult::Pass,
417                SpfResult::Pass,
418                DmarcResult::Pass,
419                DmarcResult::Pass,
420                Policy::Quarantine,
421            ),
422            // Failed mechanisms
423            (
424                "_dmarc.example.org.",
425                concat!(
426                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
427                    "rua=mailto:dmarc-feedback@example.org"
428                ),
429                "From: hello@example.org\r\n\r\n",
430                "example.org",
431                "example.org",
432                DkimResult::Fail(Error::Dkim(DkimError::SignatureExpired)),
433                SpfResult::Fail,
434                DmarcResult::None,
435                DmarcResult::None,
436                Policy::Reject,
437            ),
438        ] {
439            caches.txt_add(
440                dmarc_dns,
441                Dmarc::parse(dmarc.as_bytes()).unwrap(),
442                Instant::now() + Duration::new(3200, 0),
443            );
444
445            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
446            assert_eq!(
447                auth_message,
448                AuthenticatedMessage::from_parsed(
449                    &MessageParser::new().parse(message).unwrap(),
450                    message.as_bytes(),
451                    true
452                )
453            );
454            let signature = Signature {
455                d: signature_domain.into(),
456                ..Default::default()
457            };
458            let dkim = DkimOutput {
459                result: dkim,
460                signature: (&signature).into(),
461                report: None,
462                is_atps: false,
463            };
464            let spf = SpfOutput {
465                result: spf,
466                domain: rfc5321_mail_from_domain.to_string(),
467                report: None,
468                explanation: None,
469            };
470            let result = resolver
471                .verify_dmarc(
472                    caches.parameters(
473                        DmarcParameters::new(
474                            &auth_message,
475                            &[dkim],
476                            rfc5321_mail_from_domain,
477                            &spf,
478                        )
479                        .with_domain_suffix_fn(|d| psl::domain_str(d).unwrap_or(d)),
480                    ),
481                )
482                .await;
483            assert_eq!(result.dkim_result, expect_dkim);
484            assert_eq!(result.spf_result, expect_spf);
485            assert_eq!(result.policy, policy);
486        }
487    }
488
489    #[tokio::test]
490    async fn dmarc_verify_dkim2() {
491        use crate::Dkim2Result;
492        use crate::dkim2::{ChainLink, Dkim2Output, Signature as Dkim2Signature};
493
494        let resolver = MessageAuthenticator::new_system_conf().unwrap();
495        let caches = DummyCaches::new();
496
497        for (dmarc_dns, dmarc, message, signature_domain, dkim2_result, expect_dkim, policy) in [
498            // Strict - Pass
499            (
500                "_dmarc.example.org.",
501                "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
502                "From: hello@example.org\r\n\r\n",
503                "example.org",
504                Dkim2Result::Pass,
505                DmarcResult::Pass,
506                Policy::Reject,
507            ),
508            // Relaxed - Pass on organizational domain
509            (
510                "_dmarc.example.org.",
511                "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
512                "From: hello@example.org\r\n\r\n",
513                "subdomain.example.org",
514                Dkim2Result::Pass,
515                DmarcResult::Pass,
516                Policy::Quarantine,
517            ),
518            // Strict - Fail (subdomain does not match exactly)
519            (
520                "_dmarc.example.org.",
521                "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
522                "From: hello@example.org\r\n\r\n",
523                "subdomain.example.org",
524                Dkim2Result::Pass,
525                DmarcResult::Fail(Error::NotAligned),
526                Policy::Quarantine,
527            ),
528            // Chain did not verify - no DKIM alignment
529            (
530                "_dmarc.example.org.",
531                "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
532                "From: hello@example.org\r\n\r\n",
533                "example.org",
534                Dkim2Result::Fail(Error::NotAligned),
535                DmarcResult::None,
536                Policy::Reject,
537            ),
538        ] {
539            caches.txt_add(
540                dmarc_dns,
541                Dmarc::parse(dmarc.as_bytes()).unwrap(),
542                Instant::now() + Duration::new(3200, 0),
543            );
544
545            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
546            let signature = Dkim2Signature {
547                i: 1,
548                d: signature_domain.into(),
549                ..Default::default()
550            };
551            let dkim2 = Dkim2Output {
552                result: dkim2_result.clone(),
553                chain: vec![ChainLink {
554                    signature: &signature,
555                    instance: None,
556                    result: dkim2_result,
557                    custody_ok: true,
558                }],
559            };
560            let spf = SpfOutput {
561                result: SpfResult::None,
562                domain: "example.org".to_string(),
563                report: None,
564                explanation: None,
565            };
566            let result = resolver
567                .verify_dmarc(
568                    caches.parameters(
569                        DmarcParameters::new(&auth_message, &[], "example.org", &spf)
570                            .with_dkim2_output(&dkim2)
571                            .with_domain_suffix_fn(|d| psl::domain_str(d).unwrap_or(d)),
572                    ),
573                )
574                .await;
575            assert_eq!(result.dkim_result, expect_dkim);
576            assert_eq!(result.policy, policy);
577        }
578
579        caches.txt_add(
580            "_dmarc.example.org.",
581            Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; adkim=s; fo=1;").unwrap(),
582            Instant::now() + Duration::new(3200, 0),
583        );
584        let auth_message = AuthenticatedMessage::parse(b"From: hello@example.org\r\n\r\n").unwrap();
585        let originator = Dkim2Signature {
586            i: 1,
587            d: "other.org".into(),
588            ..Default::default()
589        };
590        let forwarder = Dkim2Signature {
591            i: 2,
592            d: "example.org".into(),
593            ..Default::default()
594        };
595        let link = |signature| ChainLink {
596            signature,
597            instance: None,
598            result: Dkim2Result::Pass,
599            custody_ok: true,
600        };
601        let dkim2 = Dkim2Output {
602            result: Dkim2Result::Pass,
603            chain: vec![link(&originator), link(&forwarder)],
604        };
605        let spf = SpfOutput {
606            result: SpfResult::None,
607            domain: "example.org".to_string(),
608            report: None,
609            explanation: None,
610        };
611        let result = resolver
612            .verify_dmarc(
613                caches.parameters(
614                    DmarcParameters::new(&auth_message, &[], "example.org", &spf)
615                        .with_dkim2_output(&dkim2)
616                        .with_domain_suffix_fn(|d| psl::domain_str(d).unwrap_or(d)),
617                ),
618            )
619            .await;
620        assert_eq!(result.dkim_result, DmarcResult::Fail(Error::NotAligned));
621    }
622
623    #[tokio::test]
624    async fn dmarc_verify_report_address() {
625        let resolver = MessageAuthenticator::new_system_conf().unwrap();
626        let caches = DummyCaches::new().with_txt(
627            "example.org._report._dmarc.external.org.",
628            Dmarc::parse(b"v=DMARC1").unwrap(),
629            Instant::now() + Duration::new(3200, 0),
630        );
631        let uris = vec![
632            URI::new("dmarc@example.org", 0),
633            URI::new("dmarc@external.org", 0),
634            URI::new("domain@other.org", 0),
635        ];
636
637        assert_eq!(
638            resolver
639                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
640                .await
641                .unwrap(),
642            vec![
643                &URI::new("dmarc@example.org", 0),
644                &URI::new("dmarc@external.org", 0),
645            ]
646        );
647    }
648}