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 std::{
8    net::{IpAddr, Ipv4Addr, Ipv6Addr},
9    sync::Arc,
10};
11
12use crate::{
13    common::cache::NoCache, AuthenticatedMessage, DkimOutput, DkimResult, DmarcOutput, DmarcResult,
14    Error, MessageAuthenticator, Parameters, ResolverCache, SpfOutput, SpfResult, Txt, MX,
15};
16
17use super::{Alignment, Dmarc, URI};
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 rfc5321_mail_from_domain: &'x str,
26    pub spf_output: &'x SpfOutput,
27    pub domain_suffix_fn: F,
28}
29
30impl MessageAuthenticator {
31    /// Verifies the DMARC policy of an RFC5321.MailFrom domain
32    pub async fn verify_dmarc<'x, TXT, MXX, IPV4, IPV6, PTR, F>(
33        &self,
34        params: impl Into<Parameters<'x, DmarcParameters<'x, F>, TXT, MXX, IPV4, IPV6, PTR>>,
35    ) -> DmarcOutput
36    where
37        TXT: ResolverCache<String, Txt> + 'x,
38        MXX: ResolverCache<String, Arc<Vec<MX>>> + 'x,
39        IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>> + 'x,
40        IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>> + 'x,
41        PTR: ResolverCache<IpAddr, Arc<Vec<String>>> + 'x,
42        F: for<'y> Fn(&'y str) -> &'y str,
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 domain_suffix_fn = params.params.domain_suffix_fn;
49        let rfc5321_mail_from_domain = params.params.rfc5321_mail_from_domain;
50        let spf_output = params.params.spf_output;
51        let mut rfc5322_from_domain = "";
52        for from in &message.from {
53            if let Some((_, domain)) = from.rsplit_once('@') {
54                if rfc5322_from_domain.is_empty() {
55                    rfc5322_from_domain = domain;
56                } else if rfc5322_from_domain != domain {
57                    // Multi-valued RFC5322.From header fields with multiple
58                    // domains MUST be exempt from DMARC checking.
59                    return DmarcOutput::default();
60                }
61            }
62        }
63        if rfc5322_from_domain.is_empty() {
64            return DmarcOutput::default();
65        }
66
67        // Obtain DMARC policy
68        let dmarc = match self
69            .dmarc_tree_walk(rfc5322_from_domain, params.cache_txt)
70            .await
71        {
72            Ok(Some(dmarc)) => dmarc,
73            Ok(None) => return DmarcOutput::default().with_domain(rfc5322_from_domain),
74            Err(err) => {
75                let err = DmarcResult::from(err);
76                return DmarcOutput::default()
77                    .with_domain(rfc5322_from_domain)
78                    .with_dkim_result(err.clone())
79                    .with_spf_result(err);
80            }
81        };
82
83        let mut output = DmarcOutput {
84            spf_result: DmarcResult::None,
85            dkim_result: DmarcResult::None,
86            domain: rfc5322_from_domain.to_string(),
87            policy: dmarc.p,
88            record: None,
89        };
90
91        let has_dkim_pass = dkim_output.iter().any(|o| o.result == DkimResult::Pass);
92        if spf_output.result == SpfResult::Pass || has_dkim_pass {
93            // Check SPF alignment
94            let rfc5322_from_subdomain = domain_suffix_fn(rfc5322_from_domain);
95            if spf_output.result == SpfResult::Pass {
96                output.spf_result = if rfc5321_mail_from_domain == rfc5322_from_domain {
97                    DmarcResult::Pass
98                } else if dmarc.aspf == Alignment::Relaxed
99                    && domain_suffix_fn(rfc5321_mail_from_domain) == rfc5322_from_subdomain
100                {
101                    output.policy = dmarc.sp;
102                    DmarcResult::Pass
103                } else {
104                    DmarcResult::Fail(Error::NotAligned)
105                };
106            }
107
108            // Check DKIM alignment
109            if has_dkim_pass {
110                output.dkim_result = if dkim_output.iter().any(|o| {
111                    o.result == DkimResult::Pass
112                        && o.signature.as_ref().unwrap().d.eq(rfc5322_from_domain)
113                }) {
114                    DmarcResult::Pass
115                } else if dmarc.adkim == Alignment::Relaxed
116                    && dkim_output.iter().any(|o| {
117                        o.result == DkimResult::Pass
118                            && domain_suffix_fn(&o.signature.as_ref().unwrap().d)
119                                == rfc5322_from_subdomain
120                    })
121                {
122                    output.policy = dmarc.sp;
123                    DmarcResult::Pass
124                } else {
125                    if dkim_output.iter().any(|o| {
126                        o.result == DkimResult::Pass
127                            && domain_suffix_fn(&o.signature.as_ref().unwrap().d)
128                                == rfc5322_from_subdomain
129                    }) {
130                        output.policy = dmarc.sp;
131                    }
132                    DmarcResult::Fail(Error::NotAligned)
133                };
134            }
135        }
136
137        output.with_record(dmarc)
138    }
139
140    /// Validates the external report e-mail addresses of a DMARC record
141    pub async fn verify_dmarc_report_address<'x>(
142        &self,
143        domain: &str,
144        addresses: &'x [URI],
145        txt_cache: Option<&impl ResolverCache<String, Txt>>,
146    ) -> Option<Vec<&'x URI>> {
147        let mut result = Vec::with_capacity(addresses.len());
148        for address in addresses {
149            if address.uri.ends_with(domain)
150                || match self
151                    .txt_lookup::<Dmarc>(
152                        format!(
153                            "{}._report._dmarc.{}.",
154                            domain,
155                            address
156                                .uri
157                                .rsplit_once('@')
158                                .map(|(_, d)| d)
159                                .unwrap_or_default()
160                        ),
161                        txt_cache,
162                    )
163                    .await
164                {
165                    Ok(_) => true,
166                    Err(Error::DnsError(_)) => return None,
167                    _ => false,
168                }
169            {
170                result.push(address);
171            }
172        }
173
174        result.into()
175    }
176
177    async fn dmarc_tree_walk(
178        &self,
179        domain: &str,
180        txt_cache: Option<&impl ResolverCache<String, Txt>>,
181    ) -> crate::Result<Option<Arc<Dmarc>>> {
182        let labels = domain.split('.').collect::<Vec<_>>();
183        let mut x = labels.len();
184        if x == 1 {
185            return Ok(None);
186        }
187        while x != 0 {
188            // Build query domain
189            let mut domain = String::with_capacity(domain.len() + 8);
190            domain.push_str("_dmarc");
191            for label in labels.iter().skip(labels.len() - x) {
192                domain.push('.');
193                domain.push_str(label);
194            }
195            domain.push('.');
196
197            // Query DMARC
198            match self.txt_lookup::<Dmarc>(domain, txt_cache).await {
199                Ok(dmarc) => {
200                    return Ok(Some(dmarc));
201                }
202                Err(Error::DnsRecordNotFound(_)) | Err(Error::InvalidRecordType) => (),
203                Err(err) => return Err(err),
204            }
205
206            // If x < 5, remove the left-most (highest-numbered) label from the subject domain.
207            // If x >= 5, remove the left-most (highest-numbered) labels from the subject
208            // domain until 4 labels remain.
209            if x < 5 {
210                x -= 1;
211            } else {
212                x = 4;
213            }
214        }
215
216        Ok(None)
217    }
218}
219
220impl<'x> DmarcParameters<'x, fn(&str) -> &str> {
221    pub fn new(
222        message: &'x AuthenticatedMessage<'x>,
223        dkim_output: &'x [DkimOutput<'x>],
224        rfc5321_mail_from_domain: &'x str,
225        spf_output: &'x SpfOutput,
226    ) -> Self {
227        Self {
228            message,
229            dkim_output,
230            rfc5321_mail_from_domain,
231            spf_output,
232            domain_suffix_fn: |d| d,
233        }
234    }
235}
236
237impl<'x, F> DmarcParameters<'x, F>
238where
239    F: for<'y> Fn(&'y str) -> &'y str,
240{
241    pub fn with_domain_suffix_fn<NewF>(self, f: NewF) -> DmarcParameters<'x, NewF>
242    where
243        NewF: for<'y> Fn(&'y str) -> &'y str,
244    {
245        DmarcParameters {
246            message: self.message,
247            dkim_output: self.dkim_output,
248            rfc5321_mail_from_domain: self.rfc5321_mail_from_domain,
249            spf_output: self.spf_output,
250            domain_suffix_fn: f,
251        }
252    }
253}
254
255impl<'x, F> From<DmarcParameters<'x, F>>
256    for Parameters<
257        'x,
258        DmarcParameters<'x, F>,
259        NoCache<String, Txt>,
260        NoCache<String, Arc<Vec<MX>>>,
261        NoCache<String, Arc<Vec<Ipv4Addr>>>,
262        NoCache<String, Arc<Vec<Ipv6Addr>>>,
263        NoCache<IpAddr, Arc<Vec<String>>>,
264    >
265where
266    F: for<'y> Fn(&'y str) -> &'y str,
267{
268    fn from(params: DmarcParameters<'x, F>) -> Self {
269        Parameters::new(params)
270    }
271}
272
273#[cfg(test)]
274#[allow(unused)]
275mod test {
276    use std::time::{Duration, Instant};
277
278    use mail_parser::MessageParser;
279
280    use crate::{
281        common::{cache::test::DummyCaches, parse::TxtRecordParser},
282        dkim::Signature,
283        dmarc::{Dmarc, Policy, URI},
284        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, Error, MessageAuthenticator,
285        SpfOutput, SpfResult,
286    };
287
288    use super::DmarcParameters;
289
290    #[tokio::test]
291    async fn dmarc_verify() {
292        let resolver = MessageAuthenticator::new_system_conf().unwrap();
293        let caches = DummyCaches::new();
294
295        for (
296            dmarc_dns,
297            dmarc,
298            message,
299            rfc5321_mail_from_domain,
300            signature_domain,
301            dkim,
302            spf,
303            expect_dkim,
304            expect_spf,
305            policy,
306        ) in [
307            // Strict - Pass
308            (
309                "_dmarc.example.org.",
310                concat!(
311                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
312                    "rua=mailto:dmarc-feedback@example.org"
313                ),
314                "From: hello@example.org\r\n\r\n",
315                "example.org",
316                "example.org",
317                DkimResult::Pass,
318                SpfResult::Pass,
319                DmarcResult::Pass,
320                DmarcResult::Pass,
321                Policy::Reject,
322            ),
323            // Relaxed - Pass
324            (
325                "_dmarc.example.org.",
326                concat!(
327                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
328                    "rua=mailto:dmarc-feedback@example.org"
329                ),
330                "From: hello@example.org\r\n\r\n",
331                "subdomain.example.org",
332                "subdomain.example.org",
333                DkimResult::Pass,
334                SpfResult::Pass,
335                DmarcResult::Pass,
336                DmarcResult::Pass,
337                Policy::Quarantine,
338            ),
339            // Strict - Fail
340            (
341                "_dmarc.example.org.",
342                concat!(
343                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
344                    "rua=mailto:dmarc-feedback@example.org"
345                ),
346                "From: hello@example.org\r\n\r\n",
347                "subdomain.example.org",
348                "subdomain.example.org",
349                DkimResult::Pass,
350                SpfResult::Pass,
351                DmarcResult::Fail(Error::NotAligned),
352                DmarcResult::Fail(Error::NotAligned),
353                Policy::Quarantine,
354            ),
355            // Strict - Pass with tree walk
356            (
357                "_dmarc.example.org.",
358                concat!(
359                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
360                    "rua=mailto:dmarc-feedback@example.org"
361                ),
362                "From: hello@a.b.c.example.org\r\n\r\n",
363                "a.b.c.example.org",
364                "a.b.c.example.org",
365                DkimResult::Pass,
366                SpfResult::Pass,
367                DmarcResult::Pass,
368                DmarcResult::Pass,
369                Policy::Reject,
370            ),
371            // Relaxed - Pass with tree walk
372            (
373                "_dmarc.c.example.org.",
374                concat!(
375                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
376                    "rua=mailto:dmarc-feedback@example.org"
377                ),
378                "From: hello@a.b.c.example.org\r\n\r\n",
379                "example.org",
380                "example.org",
381                DkimResult::Pass,
382                SpfResult::Pass,
383                DmarcResult::Pass,
384                DmarcResult::Pass,
385                Policy::Quarantine,
386            ),
387            // Relaxed - Pass with tree walk and different subdomains
388            (
389                "_dmarc.c.example.org.",
390                concat!(
391                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
392                    "rua=mailto:dmarc-feedback@example.org"
393                ),
394                "From: hello@a.b.c.example.org\r\n\r\n",
395                "z.example.org",
396                "z.example.org",
397                DkimResult::Pass,
398                SpfResult::Pass,
399                DmarcResult::Pass,
400                DmarcResult::Pass,
401                Policy::Quarantine,
402            ),
403            // Failed mechanisms
404            (
405                "_dmarc.example.org.",
406                concat!(
407                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
408                    "rua=mailto:dmarc-feedback@example.org"
409                ),
410                "From: hello@example.org\r\n\r\n",
411                "example.org",
412                "example.org",
413                DkimResult::Fail(Error::SignatureExpired),
414                SpfResult::Fail,
415                DmarcResult::None,
416                DmarcResult::None,
417                Policy::Reject,
418            ),
419        ] {
420            caches.txt_add(
421                dmarc_dns,
422                Dmarc::parse(dmarc.as_bytes()).unwrap(),
423                Instant::now() + Duration::new(3200, 0),
424            );
425
426            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
427            assert_eq!(
428                auth_message,
429                AuthenticatedMessage::from_parsed(
430                    &MessageParser::new().parse(message).unwrap(),
431                    true
432                )
433            );
434            let signature = Signature {
435                d: signature_domain.into(),
436                ..Default::default()
437            };
438            let dkim = DkimOutput {
439                result: dkim,
440                signature: (&signature).into(),
441                report: None,
442                is_atps: false,
443            };
444            let spf = SpfOutput {
445                result: spf,
446                domain: rfc5321_mail_from_domain.to_string(),
447                report: None,
448                explanation: None,
449            };
450            let result = resolver
451                .verify_dmarc(
452                    caches.parameters(
453                        DmarcParameters::new(
454                            &auth_message,
455                            &[dkim],
456                            rfc5321_mail_from_domain,
457                            &spf,
458                        )
459                        .with_domain_suffix_fn(|d| psl::domain_str(d).unwrap_or(d)),
460                    ),
461                )
462                .await;
463            assert_eq!(result.dkim_result, expect_dkim);
464            assert_eq!(result.spf_result, expect_spf);
465            assert_eq!(result.policy, policy);
466        }
467    }
468
469    #[tokio::test]
470    async fn dmarc_verify_report_address() {
471        let resolver = MessageAuthenticator::new_system_conf().unwrap();
472        let caches = DummyCaches::new().with_txt(
473            "example.org._report._dmarc.external.org.",
474            Dmarc::parse(b"v=DMARC1").unwrap(),
475            Instant::now() + Duration::new(3200, 0),
476        );
477        let uris = vec![
478            URI::new("dmarc@example.org", 0),
479            URI::new("dmarc@external.org", 0),
480            URI::new("domain@other.org", 0),
481        ];
482
483        assert_eq!(
484            resolver
485                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
486                .await
487                .unwrap(),
488            vec![
489                &URI::new("dmarc@example.org", 0),
490                &URI::new("dmarc@external.org", 0),
491            ]
492        );
493    }
494}