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, DmarcRecord, Policy, Psd};
8use crate::DnsError;
9use crate::dns::{DnsCache, Negative};
10use crate::{
11    AuthenticatedMessage, Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
12    MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult, TxtRecord,
13    dkim2::{Dkim2Output, Verification},
14    dns::cache::NoCache,
15    dns::to_a_label,
16};
17use std::{borrow::Cow, net::Ipv4Addr, sync::Arc};
18
19const DMARC_PREFIX: &str = "_dmarc.";
20const REPORT_PREFIX: &str = "._report._dmarc.";
21
22/// Input of [`MessageAuthenticator::verify_dmarc`]: the message and the
23/// results of the underlying authentication checks.
24///
25/// Built with [`DmarcParameters::new`], optionally extended with
26/// [`DmarcParameters::with_dkim2_output`], and optionally wrapped in
27/// [`Parameters`] to supply a DNS cache.
28pub struct DmarcParameters<'x> {
29    /// The message; its RFC5322.From header gives the Author Domain.
30    pub message: &'x AuthenticatedMessage<'x>,
31    /// Output of [`MessageAuthenticator::verify_dkim`] for the message.
32    pub dkim_output: &'x [DkimOutput<'x>],
33    /// Output of DKIM2 verification, if performed; only instance 1 is used
34    /// for alignment.
35    pub dkim2_output: Option<&'x Dkim2Output<'x>>,
36    /// The RFC5321.MailFrom domain (or the HELO domain for a null
37    /// reverse-path) that SPF was evaluated for.
38    pub mail_from_domain: &'x str,
39    /// Output of SPF verification for `mail_from_domain`.
40    pub spf_output: &'x SpfOutput,
41}
42
43impl MessageAuthenticator {
44    /// Evaluates the DMARC policy of a message (RFC 9989, Section 4.10).
45    ///
46    /// The Author Domain is taken from the RFC5322.From header. Messages
47    /// without a From domain, or whose From header lists several different
48    /// domains, are exempt and yield an empty output (result `None`).
49    ///
50    /// DNS lookups performed:
51    ///
52    /// - A DNS Tree Walk of TXT queries for `_dmarc.<name>`, from the Author
53    ///   Domain up to the top-level domain, capped at eight queries. A record
54    ///   with `psd=y` or `psd=n` stops the walk. The Author Domain's own
55    ///   record applies if present, otherwise the Organizational Domain's,
56    ///   otherwise the shortest one found.
57    /// - An A query for the Author Domain when a record found above it has
58    ///   `np` different from `sp`, to tell a non-existent subdomain (NXDOMAIN,
59    ///   RFC 8020) from an existing one.
60    /// - Further Tree Walks to find the Organizational Domain of SPF and DKIM
61    ///   identifiers under relaxed alignment.
62    ///
63    /// A record without a valid `p` tag is treated as `p=none` when it has a
64    /// valid `rua` tag; otherwise DMARC does not apply.
65    ///
66    /// The returned [`DmarcOutput`] carries per-mechanism results
67    /// ([`DmarcOutput::spf_result`], [`DmarcOutput::dkim_result`]) and the
68    /// overall [`DmarcOutput::result`]:
69    ///
70    /// - [`DmarcResult::Pass`]: SPF or DKIM produced an aligned pass.
71    /// - [`DmarcResult::Fail`]: a record applies and no mechanism produced
72    ///   an aligned pass.
73    /// - [`DmarcResult::TempError`]: a transient DNS error prevented the
74    ///   evaluation.
75    /// - [`DmarcResult::PermError`]: the DMARC record could not be
76    ///   retrieved.
77    /// - [`DmarcResult::None`]: DMARC does not apply to the message.
78    ///
79    /// # Example
80    ///
81    /// ```rust,no_run
82    /// use mail_auth::{
83    ///     AuthenticatedMessage, DmarcResult, MessageAuthenticator, dmarc::Policy,
84    ///     dmarc::verify::DmarcParameters, spf::verify::SpfParameters,
85    /// };
86    ///
87    /// # async fn run(authenticator: &MessageAuthenticator, raw_message: &[u8]) {
88    /// let message = AuthenticatedMessage::parse(raw_message).unwrap();
89    /// let dkim_output = authenticator.verify_dkim(&message).await;
90    /// let spf_output = authenticator
91    ///     .verify_spf(SpfParameters::mail_from(
92    ///         "192.0.2.1".parse().unwrap(),
93    ///         "mx.example.org",
94    ///         "mx.my-host.org",
95    ///         "sender@example.org",
96    ///     ))
97    ///     .await;
98    /// let dmarc_output = authenticator
99    ///     .verify_dmarc(DmarcParameters::new(
100    ///         &message,
101    ///         &dkim_output,
102    ///         "example.org",
103    ///         &spf_output,
104    ///     ))
105    ///     .await;
106    ///
107    /// if matches!(dmarc_output.result(), DmarcResult::Fail(_))
108    ///     && dmarc_output.policy() == Policy::Reject
109    /// {
110    ///     println!("reject message from {}", dmarc_output.domain());
111    /// }
112    /// # }
113    /// ```
114    pub async fn verify_dmarc<'x, C>(
115        &self,
116        params: impl Into<Parameters<'x, DmarcParameters<'x>, C>>,
117    ) -> DmarcOutput
118    where
119        C: DnsCache + 'x,
120    {
121        let params = params.into();
122        let message = params.input.message;
123        let dkim_output = params.input.dkim_output;
124        let dkim2_output = params.input.dkim2_output;
125        let mail_from_domain = to_a_label(params.input.mail_from_domain);
126        let mail_from_domain = mail_from_domain.as_ref();
127        let spf_output = params.input.spf_output;
128        let cache_txt = params.txt_cache();
129        let cache_ipv4 = params.ipv4_cache();
130        let mut rfc5322_from_domain = Cow::Borrowed("");
131        for from in &message.from {
132            if let Some((_, domain)) = from.rsplit_once('@') {
133                let domain = to_a_label(domain);
134                if rfc5322_from_domain.is_empty() {
135                    rfc5322_from_domain = domain;
136                } else if rfc5322_from_domain != domain {
137                    return DmarcOutput::default();
138                }
139            }
140        }
141        if rfc5322_from_domain.is_empty() {
142            return DmarcOutput::default();
143        }
144        let rfc5322_from_domain = rfc5322_from_domain.as_ref();
145
146        let walk = match self.dmarc_tree_walk(rfc5322_from_domain, cache_txt).await {
147            Ok(walk) => walk,
148            Err(err) => {
149                let err = DmarcResult::from(err);
150                return DmarcOutput::default()
151                    .with_domain(rfc5322_from_domain)
152                    .with_dkim_result(err.clone())
153                    .with_spf_result(err);
154            }
155        };
156        if walk.is_empty() {
157            return DmarcOutput::default().with_domain(rfc5322_from_domain);
158        }
159
160        let author_org =
161            organizational_domain(&walk, rfc5322_from_domain).unwrap_or(rfc5322_from_domain);
162
163        let (record, is_author_record) =
164            if let Some((_, record)) = walk.iter().find(|(name, _)| *name == rfc5322_from_domain) {
165                (record, true)
166            } else if let Some((_, record)) = walk
167                .iter()
168                .find(|(name, _)| *name == author_org)
169                .or_else(|| walk.last())
170            {
171                (record, false)
172            } else {
173                return DmarcOutput::default().with_domain(rfc5322_from_domain);
174            };
175
176        let mut policy = if record.p == Policy::Unspecified {
177            if record.rua.is_empty() {
178                return DmarcOutput::default().with_domain(rfc5322_from_domain);
179            }
180            Policy::None
181        } else if is_author_record {
182            record.p
183        } else if record.np != record.sp
184            && self.domain_exists(rfc5322_from_domain, cache_ipv4).await == Some(false)
185        {
186            record.np
187        } else {
188            record.sp
189        };
190
191        if record.t {
192            policy = match policy {
193                Policy::Reject => Policy::Quarantine,
194                Policy::Quarantine => Policy::None,
195                other => other,
196            };
197        }
198        let aspf = record.aspf;
199        let adkim = record.adkim;
200
201        let mut output = DmarcOutput {
202            spf_result: DmarcResult::None,
203            dkim_result: DmarcResult::None,
204            domain: rfc5322_from_domain.to_string(),
205            policy,
206            record: None,
207        };
208
209        let dkim_signatures = dkim_output
210            .iter()
211            .filter_map(|o| match (&o.result, &o.signature) {
212                (DkimResult::Pass, Some(signature)) => Some((signature.d.as_str(), None)),
213                (DkimResult::TempError(err), Some(signature)) => {
214                    Some((signature.d.as_str(), Some(err)))
215                }
216                _ => None,
217            })
218            .chain(dkim2_output.and_then(|o| {
219                (o.verification() == Verification::Full)
220                    .then_some(o)?
221                    .chain
222                    .iter()
223                    .find(|link| link.signature.i == 1)
224                    .and_then(|link| match (&o.result, &link.result) {
225                        (Dkim2Result::Pass, Dkim2Result::Pass) => {
226                            Some((link.signature.d.as_str(), None))
227                        }
228                        (Dkim2Result::TempError(_), Dkim2Result::TempError(err)) => {
229                            Some((link.signature.d.as_str(), Some(err)))
230                        }
231                        _ => None,
232                    })
233            }))
234            .map(|(d, temp_error)| (to_a_label(d), temp_error))
235            .collect::<Vec<_>>();
236
237        let mut org_memo: Vec<(&str, &str)> = vec![(rfc5322_from_domain, author_org)];
238
239        if matches!(spf_output.result, SpfResult::Pass | SpfResult::TempError) {
240            let spf_pass = spf_output.result == SpfResult::Pass;
241            output.spf_result = match self
242                .is_aligned(
243                    mail_from_domain,
244                    rfc5322_from_domain,
245                    author_org,
246                    aspf,
247                    cache_txt,
248                    &mut org_memo,
249                )
250                .await
251            {
252                Ok(true) if spf_pass => DmarcResult::Pass,
253                Ok(true) => DmarcResult::TempError(Error::Dns(DnsError::Resolver(String::new()))),
254                Ok(false) if spf_pass => DmarcResult::Fail(Error::NotAligned),
255                Ok(false) => DmarcResult::None,
256                Err(err) => DmarcResult::TempError(err),
257            };
258        }
259
260        let mut has_dkim_pass = false;
261        let mut dkim_temp_error = None;
262        for (d, temp_error) in &dkim_signatures {
263            if temp_error.is_some() && dkim_temp_error.is_some() {
264                continue;
265            }
266            has_dkim_pass |= temp_error.is_none();
267            match self
268                .is_aligned(
269                    d.as_ref(),
270                    rfc5322_from_domain,
271                    author_org,
272                    adkim,
273                    cache_txt,
274                    &mut org_memo,
275                )
276                .await
277            {
278                Ok(true) => match temp_error {
279                    None => {
280                        output.dkim_result = DmarcResult::Pass;
281                        return output.with_record(Arc::clone(record));
282                    }
283                    Some(err) => dkim_temp_error = Some((*err).clone()),
284                },
285                Ok(false) => (),
286                Err(err) => {
287                    dkim_temp_error.get_or_insert(err);
288                }
289            }
290        }
291
292        output.dkim_result = if let Some(err) = dkim_temp_error {
293            DmarcResult::TempError(err)
294        } else if has_dkim_pass {
295            DmarcResult::Fail(Error::NotAligned)
296        } else {
297            DmarcResult::None
298        };
299
300        output.with_record(Arc::clone(record))
301    }
302
303    /// Returns the reporting addresses that are authorized to receive DMARC
304    /// reports for a policy domain (RFC 9989).
305    ///
306    /// Takes a `(policy_domain, addresses)` pair, optionally wrapped in
307    /// [`Parameters`] to supply a DNS cache. `addresses` is usually
308    /// [`DmarcRecord::rua`] or [`DmarcRecord::ruf`], but any `AsRef<str>`
309    /// holding an email address works. An address is authorized when its
310    /// domain is the policy domain or a subdomain of it (internal), or, for
311    /// an external destination, when a `v=DMARC1` TXT record exists at
312    /// `<policy_domain>._report._dmarc.<destination_domain>`. One TXT lookup
313    /// is performed per external address. Addresses are returned in input
314    /// order.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`Error::Dns`] with [`DnsError::Resolver`] when a lookup fails
319    /// with a transient resolver error. A missing or invalid authorization
320    /// record is not an error: the address is left out.
321    ///
322    /// # Example
323    ///
324    /// ```rust,no_run
325    /// use mail_auth::{DmarcOutput, MessageAuthenticator};
326    ///
327    /// # async fn run(authenticator: &MessageAuthenticator, output: &DmarcOutput) -> mail_auth::Result<()> {
328    /// if let Some(record) = output.record() {
329    ///     let authorized = authenticator
330    ///         .authorized_report_addresses((output.domain(), record.rua()))
331    ///         .await?;
332    ///     for uri in authorized {
333    ///         println!("send aggregate report to {}", uri.uri());
334    ///     }
335    /// }
336    /// # Ok(())
337    /// # }
338    /// ```
339    pub async fn authorized_report_addresses<'x, 'y, T, C>(
340        &self,
341        params: impl Into<Parameters<'y, (&'y str, &'x [T]), C>>,
342    ) -> crate::Result<Vec<&'x T>>
343    where
344        T: AsRef<str> + 'x,
345        C: DnsCache + 'y,
346    {
347        let params = params.into();
348        let (domain, addresses) = params.input;
349        let txt_cache = params.txt_cache();
350        let domain = to_a_label(domain);
351        let domain = domain.as_ref();
352        let mut result = Vec::with_capacity(addresses.len());
353        let mut key = String::new();
354        for address in addresses {
355            let address_ref = address.as_ref();
356            let address_domain = to_a_label(
357                address_ref
358                    .rsplit_once('@')
359                    .map(|(_, d)| d)
360                    .unwrap_or_default(),
361            );
362            let address_domain = address_domain.as_ref();
363            let is_internal = address_domain == domain
364                || address_domain
365                    .strip_suffix(domain)
366                    .is_some_and(|prefix| prefix.ends_with('.'));
367            let is_authorized = is_internal || {
368                key.clear();
369                key.reserve(domain.len() + REPORT_PREFIX.len() + address_domain.len() + 1);
370                key.push_str(domain);
371                key.push_str(REPORT_PREFIX);
372                key.push_str(address_domain);
373                key.push('.');
374                match self.txt_lookup::<DmarcRecord>(&key, txt_cache).await {
375                    Ok(_) => true,
376                    Err(err @ Error::Dns(DnsError::Resolver(_))) => return Err(err),
377                    _ => false,
378                }
379            };
380            if is_authorized {
381                result.push(address);
382            }
383        }
384
385        Ok(result)
386    }
387
388    /// Performs a DNS Tree Walk (RFC 9989 Section 4.10) starting at `domain`
389    /// and returns every valid DMARC Policy Record found from the starting
390    /// point (longest name) up to the top-level domain (shortest name).
391    async fn dmarc_tree_walk<'x>(
392        &self,
393        domain: &'x str,
394        txt_cache: Option<&impl ResolverCache<Box<str>, TxtRecord>>,
395    ) -> crate::Result<Vec<(&'x str, Arc<DmarcRecord>)>> {
396        let total = domain.split('.').filter(|l| !l.is_empty()).count();
397        let mut found = Vec::new();
398        if total < 2 {
399            return Ok(found);
400        }
401
402        let mut count = total;
403        let mut key = String::with_capacity(domain.len() + DMARC_PREFIX.len() + 1);
404        loop {
405            let name = drop_leftmost_labels(domain, total - count);
406            key.clear();
407            key.push_str(DMARC_PREFIX);
408            key.push_str(name);
409            key.push('.');
410            match self.txt_lookup::<DmarcRecord>(&key, txt_cache).await {
411                Ok(dmarc) => {
412                    let stop = matches!(dmarc.psd, Psd::Yes | Psd::No);
413                    found.push((name, dmarc));
414                    if stop {
415                        break;
416                    }
417                }
418                Err(Error::Dns(DnsError::RecordNotFound(_)))
419                | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
420                Err(err) => return Err(err),
421            }
422
423            if count == 1 {
424                break;
425            }
426            count = if count >= 8 { 7 } else { count - 1 };
427        }
428
429        Ok(found)
430    }
431
432    /// Determines whether `domain` exists in the DNS per RFC 8020.
433    async fn domain_exists(
434        &self,
435        domain: &str,
436        cache_ipv4: Option<&impl ResolverCache<Box<str>, Result<RecordSet<Ipv4Addr>, Negative>>>,
437    ) -> Option<bool> {
438        match self.ipv4_lookup(domain, cache_ipv4).await {
439            Ok(_) => Some(true),
440            Err(Error::Dns(DnsError::RecordNotFound(code))) => {
441                Some(code != crate::dns::DNS_RCODE_NXDOMAIN)
442            }
443            Err(_) => None,
444        }
445    }
446
447    async fn is_aligned<'x>(
448        &self,
449        domain: &'x str,
450        author_domain: &'x str,
451        author_org: &'x str,
452        alignment: Alignment,
453        txt_cache: Option<&impl ResolverCache<Box<str>, TxtRecord>>,
454        memo: &mut Vec<(&'x str, &'x str)>,
455    ) -> crate::Result<bool> {
456        Ok(domain == author_domain
457            || (alignment == Alignment::Relaxed
458                && domain
459                    .strip_suffix(author_org)
460                    .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('.'))
461                && self
462                    .organizational_domain_of(domain, txt_cache, memo)
463                    .await?
464                    == author_org))
465    }
466
467    /// Determines the Organizational Domain of `domain` via a DNS Tree Walk (RFC 9989 Section 4.10.2).
468    async fn organizational_domain_of<'x>(
469        &self,
470        domain: &'x str,
471        txt_cache: Option<&impl ResolverCache<Box<str>, TxtRecord>>,
472        memo: &mut Vec<(&'x str, &'x str)>,
473    ) -> crate::Result<&'x str> {
474        if let Some(&(_, org)) = memo.iter().find(|(d, _)| *d == domain) {
475            return Ok(org);
476        }
477        let org = match self.dmarc_tree_walk(domain, txt_cache).await {
478            Ok(walk) => organizational_domain(&walk, domain).unwrap_or(domain),
479            Err(err @ Error::Dns(DnsError::Resolver(_))) => return Err(err),
480            Err(_) => domain,
481        };
482        memo.push((domain, org));
483        Ok(org)
484    }
485}
486
487/// Selects the Organizational Domain from the set of DMARC Policy Records
488/// retrieved by a Tree Walk (RFC 9989 Section 4.10.2). The `walk` is ordered
489/// from the longest name (the starting domain) to the shortest.
490fn organizational_domain<'x>(
491    walk: &[(&'x str, Arc<DmarcRecord>)],
492    start: &'x str,
493) -> Option<&'x str> {
494    for (name, record) in walk {
495        match record.psd {
496            Psd::No => return Some(name),
497            Psd::Yes if *name != start => return Some(one_label_below(name, start)),
498            _ => {}
499        }
500    }
501    walk.last().map(|(name, _)| *name)
502}
503
504/// Returns the domain one label below `psd_name` on the path toward `start`.
505fn one_label_below<'x>(psd_name: &str, start: &'x str) -> &'x str {
506    let depth = psd_name.split('.').filter(|l| !l.is_empty()).count() + 1;
507    let start_labels = start.split('.').filter(|l| !l.is_empty()).count();
508    drop_leftmost_labels(start, start_labels.saturating_sub(depth))
509}
510
511/// Returns the suffix of `domain` after removing its `n` leftmost labels.
512fn drop_leftmost_labels(domain: &str, n: usize) -> &str {
513    let mut suffix = domain;
514    for _ in 0..n {
515        match suffix.split_once('.') {
516            Some((_, rest)) => suffix = rest,
517            None => return "",
518        }
519    }
520    suffix
521}
522
523impl<'x> DmarcParameters<'x> {
524    /// Creates the parameters from the message, the DKIM verification
525    /// output, the domain SPF was evaluated for (RFC5321.MailFrom domain, or
526    /// the HELO domain for a null reverse-path) and the SPF output.
527    pub fn new(
528        message: &'x AuthenticatedMessage<'x>,
529        dkim_output: &'x [DkimOutput<'x>],
530        mail_from_domain: &'x str,
531        spf_output: &'x SpfOutput,
532    ) -> Self {
533        Self {
534            message,
535            dkim_output,
536            dkim2_output: None,
537            mail_from_domain,
538            spf_output,
539        }
540    }
541
542    /// Adds the DKIM2 verification output; a passing instance 1 signature
543    /// takes part in DKIM alignment.
544    pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
545        self.dkim2_output = Some(dkim2_output);
546        self
547    }
548}
549
550impl<'x> From<DmarcParameters<'x>> for Parameters<'x, DmarcParameters<'x>, NoCache> {
551    fn from(params: DmarcParameters<'x>) -> Self {
552        Parameters::new(params)
553    }
554}
555
556impl<'x, 'y, T> From<(&'y str, &'x [T])> for Parameters<'y, (&'y str, &'x [T]), NoCache> {
557    fn from(params: (&'y str, &'x [T])) -> Self {
558        Parameters::new(params)
559    }
560}
561
562#[cfg(test)]
563#[allow(unused)]
564mod tests;