1use super::{Alignment, Dmarc, Policy, Psd};
8use crate::DnsError;
9use crate::{
10 AuthenticatedMessage, Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error, MX,
11 MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult, Txt,
12 common::cache::NoCache, common::to_a_label, dkim2::Dkim2Output,
13};
14use std::{
15 borrow::Cow,
16 net::{IpAddr, Ipv4Addr, Ipv6Addr},
17 sync::Arc,
18};
19
20const DMARC_PREFIX: &str = "_dmarc.";
21const REPORT_PREFIX: &str = "._report._dmarc.";
22
23pub struct DmarcParameters<'x> {
24 pub message: &'x AuthenticatedMessage<'x>,
25 pub dkim_output: &'x [DkimOutput<'x>],
26 pub dkim2_output: Option<&'x Dkim2Output<'x>>,
27 pub rfc5321_mail_from_domain: &'x str,
28 pub spf_output: &'x SpfOutput,
29}
30
31impl MessageAuthenticator {
32 pub async fn verify_dmarc<'x, TXT, MXX, IPV4, IPV6, PTR>(
34 &self,
35 params: impl Into<Parameters<'x, DmarcParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
36 ) -> DmarcOutput
37 where
38 TXT: ResolverCache<Box<str>, Txt> + 'x,
39 MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
40 IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
41 IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
42 PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
43 {
44 let params = params.into();
46 let message = params.params.message;
47 let dkim_output = params.params.dkim_output;
48 let dkim2_output = params.params.dkim2_output;
49 let rfc5321_mail_from_domain = to_a_label(params.params.rfc5321_mail_from_domain);
50 let rfc5321_mail_from_domain = rfc5321_mail_from_domain.as_ref();
51 let spf_output = params.params.spf_output;
52 let cache_txt = params.cache_txt;
53 let cache_ipv4 = params.cache_ipv4;
54 let mut rfc5322_from_domain = Cow::Borrowed("");
55 for from in &message.from {
56 if let Some((_, domain)) = from.rsplit_once('@') {
57 let domain = to_a_label(domain);
58 if rfc5322_from_domain.is_empty() {
59 rfc5322_from_domain = domain;
60 } else if rfc5322_from_domain != domain {
61 return DmarcOutput::default();
64 }
65 }
66 }
67 if rfc5322_from_domain.is_empty() {
68 return DmarcOutput::default();
69 }
70 let rfc5322_from_domain = rfc5322_from_domain.as_ref();
71
72 let walk = match self.dmarc_tree_walk(rfc5322_from_domain, cache_txt).await {
75 Ok(walk) => walk,
76 Err(err) => {
77 let err = DmarcResult::from(err);
78 return DmarcOutput::default()
79 .with_domain(rfc5322_from_domain)
80 .with_dkim_result(err.clone())
81 .with_spf_result(err);
82 }
83 };
84 if walk.is_empty() {
85 return DmarcOutput::default().with_domain(rfc5322_from_domain);
86 }
87
88 let author_org =
90 organizational_domain(&walk, rfc5322_from_domain).unwrap_or(rfc5322_from_domain);
91
92 let (record, is_author_record) =
96 if let Some((_, record)) = walk.iter().find(|(name, _)| *name == rfc5322_from_domain) {
97 (record, true)
98 } else if let Some((_, record)) = walk
99 .iter()
100 .find(|(name, _)| *name == author_org)
101 .or_else(|| walk.last())
102 {
103 (record, false)
104 } else {
105 return DmarcOutput::default().with_domain(rfc5322_from_domain);
106 };
107
108 let mut policy = if record.p == Policy::Unspecified {
112 if record.rua.is_empty() {
113 return DmarcOutput::default().with_domain(rfc5322_from_domain);
114 }
115 Policy::None
116 } else if is_author_record {
117 record.p
119 } else if record.np != record.sp
120 && self.domain_exists(rfc5322_from_domain, cache_ipv4).await == Some(false)
121 {
122 record.np
125 } else {
126 record.sp
128 };
129
130 if record.t {
133 policy = match policy {
134 Policy::Reject => Policy::Quarantine,
135 Policy::Quarantine => Policy::None,
136 other => other,
137 };
138 }
139 let aspf = record.aspf;
140 let adkim = record.adkim;
141
142 let mut output = DmarcOutput {
143 spf_result: DmarcResult::None,
144 dkim_result: DmarcResult::None,
145 domain: rfc5322_from_domain.to_string(),
146 policy,
147 record: None,
148 };
149
150 let dkim_signatures = dkim_output
151 .iter()
152 .filter_map(|o| match (&o.result, &o.signature) {
153 (DkimResult::Pass, Some(signature)) => Some((signature.d.as_str(), None)),
154 (DkimResult::TempError(err), Some(signature)) => {
155 Some((signature.d.as_str(), Some(err)))
156 }
157 _ => None,
158 })
159 .chain(dkim2_output.and_then(|o| {
160 o.chain
161 .iter()
162 .find(|link| link.signature.i == 1)
163 .and_then(|link| match (&o.result, &link.result) {
164 (Dkim2Result::Pass, Dkim2Result::Pass) => {
165 Some((link.signature.d.as_str(), None))
166 }
167 (Dkim2Result::TempError(_), Dkim2Result::TempError(err)) => {
168 Some((link.signature.d.as_str(), Some(err)))
169 }
170 _ => None,
171 })
172 }))
173 .map(|(d, temp_error)| (to_a_label(d), temp_error))
174 .collect::<Vec<_>>();
175
176 let mut org_memo: Vec<(&str, &str)> = vec![(rfc5322_from_domain, author_org)];
178
179 if matches!(spf_output.result, SpfResult::Pass | SpfResult::TempError) {
182 let spf_pass = spf_output.result == SpfResult::Pass;
183 output.spf_result = match self
184 .is_aligned(
185 rfc5321_mail_from_domain,
186 rfc5322_from_domain,
187 author_org,
188 aspf,
189 cache_txt,
190 &mut org_memo,
191 )
192 .await
193 {
194 Ok(true) if spf_pass => DmarcResult::Pass,
195 Ok(true) => DmarcResult::TempError(Error::Dns(DnsError::Resolver(String::new()))),
196 Ok(false) if spf_pass => DmarcResult::Fail(Error::NotAligned),
197 Ok(false) => DmarcResult::None,
198 Err(err) => DmarcResult::TempError(err),
199 };
200 }
201
202 let mut has_dkim_pass = false;
204 let mut dkim_temp_error = None;
205 for (d, temp_error) in &dkim_signatures {
206 if temp_error.is_some() && dkim_temp_error.is_some() {
207 continue;
208 }
209 has_dkim_pass |= temp_error.is_none();
210 match self
211 .is_aligned(
212 d.as_ref(),
213 rfc5322_from_domain,
214 author_org,
215 adkim,
216 cache_txt,
217 &mut org_memo,
218 )
219 .await
220 {
221 Ok(true) => match temp_error {
222 None => {
223 output.dkim_result = DmarcResult::Pass;
224 return output.with_record(Arc::clone(record));
225 }
226 Some(err) => dkim_temp_error = Some((*err).clone()),
227 },
228 Ok(false) => (),
229 Err(err) => {
230 dkim_temp_error.get_or_insert(err);
231 }
232 }
233 }
234
235 output.dkim_result = if let Some(err) = dkim_temp_error {
236 DmarcResult::TempError(err)
237 } else if has_dkim_pass {
238 DmarcResult::Fail(Error::NotAligned)
239 } else {
240 DmarcResult::None
241 };
242
243 output.with_record(Arc::clone(record))
244 }
245
246 pub async fn verify_dmarc_report_address<'x, T: AsRef<str>>(
248 &self,
249 domain: &str,
250 addresses: &'x [T],
251 txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
252 ) -> Option<Vec<&'x T>> {
253 let domain = to_a_label(domain);
254 let domain = domain.as_ref();
255 let mut result = Vec::with_capacity(addresses.len());
256 let mut key = String::new();
257 for address in addresses {
258 let address_ref = address.as_ref();
259 let address_domain = to_a_label(
260 address_ref
261 .rsplit_once('@')
262 .map(|(_, d)| d)
263 .unwrap_or_default(),
264 );
265 let address_domain = address_domain.as_ref();
266 let is_internal = address_domain == domain
269 || address_domain
270 .strip_suffix(domain)
271 .is_some_and(|prefix| prefix.ends_with('.'));
272 let is_authorized = is_internal || {
273 key.clear();
274 key.reserve(domain.len() + REPORT_PREFIX.len() + address_domain.len() + 1);
275 key.push_str(domain);
276 key.push_str(REPORT_PREFIX);
277 key.push_str(address_domain);
278 key.push('.');
279 match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
280 Ok(_) => true,
281 Err(Error::Dns(DnsError::Resolver(_))) => return None,
282 _ => false,
283 }
284 };
285 if is_authorized {
286 result.push(address);
287 }
288 }
289
290 result.into()
291 }
292
293 async fn dmarc_tree_walk<'x>(
297 &self,
298 domain: &'x str,
299 txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
300 ) -> crate::Result<Vec<(&'x str, Arc<Dmarc>)>> {
301 let total = domain.split('.').filter(|l| !l.is_empty()).count();
302 let mut found = Vec::new();
303 if total < 2 {
304 return Ok(found);
305 }
306
307 let mut count = total;
311 let mut key = String::with_capacity(domain.len() + DMARC_PREFIX.len() + 1);
312 loop {
313 let name = drop_leftmost_labels(domain, total - count);
314 key.clear();
315 key.push_str(DMARC_PREFIX);
316 key.push_str(name);
317 key.push('.');
318 match self.txt_lookup::<Dmarc>(&key, txt_cache).await {
319 Ok(dmarc) => {
320 let stop = matches!(dmarc.psd, Psd::Yes | Psd::No);
322 found.push((name, dmarc));
323 if stop {
324 break;
325 }
326 }
327 Err(Error::Dns(DnsError::RecordNotFound(_)))
328 | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
329 Err(err) => return Err(err),
330 }
331
332 if count == 1 {
333 break;
334 }
335 count = if count >= 8 { 7 } else { count - 1 };
336 }
337
338 Ok(found)
339 }
340
341 async fn domain_exists(
343 &self,
344 domain: &str,
345 cache_ipv4: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv4Addr>>>,
346 ) -> Option<bool> {
347 match self.ipv4_lookup(domain, cache_ipv4).await {
348 Ok(_) => Some(true),
350 Err(Error::Dns(DnsError::RecordNotFound(code))) => {
353 Some(code != crate::DNS_RCODE_NXDOMAIN)
354 }
355 Err(_) => None,
356 }
357 }
358
359 async fn is_aligned<'x>(
360 &self,
361 domain: &'x str,
362 author_domain: &'x str,
363 author_org: &'x str,
364 alignment: Alignment,
365 txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
366 memo: &mut Vec<(&'x str, &'x str)>,
367 ) -> crate::Result<bool> {
368 Ok(domain == author_domain
369 || (alignment == Alignment::Relaxed
370 && domain
371 .strip_suffix(author_org)
372 .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('.'))
373 && self
374 .organizational_domain_of(domain, txt_cache, memo)
375 .await?
376 == author_org))
377 }
378
379 async fn organizational_domain_of<'x>(
381 &self,
382 domain: &'x str,
383 txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
384 memo: &mut Vec<(&'x str, &'x str)>,
385 ) -> crate::Result<&'x str> {
386 if let Some(&(_, org)) = memo.iter().find(|(d, _)| *d == domain) {
387 return Ok(org);
388 }
389 let org = match self.dmarc_tree_walk(domain, txt_cache).await {
390 Ok(walk) => organizational_domain(&walk, domain).unwrap_or(domain),
391 Err(err @ Error::Dns(DnsError::Resolver(_))) => return Err(err),
392 Err(_) => domain,
393 };
394 memo.push((domain, org));
395 Ok(org)
396 }
397}
398
399fn organizational_domain<'x>(walk: &[(&'x str, Arc<Dmarc>)], start: &'x str) -> Option<&'x str> {
403 for (name, record) in walk {
404 match record.psd {
405 Psd::No => return Some(name),
406 Psd::Yes if *name != start => return Some(one_label_below(name, start)),
407 _ => {}
408 }
409 }
410 walk.last().map(|(name, _)| *name)
411}
412
413fn one_label_below<'x>(psd_name: &str, start: &'x str) -> &'x str {
415 let depth = psd_name.split('.').filter(|l| !l.is_empty()).count() + 1;
416 let start_labels = start.split('.').filter(|l| !l.is_empty()).count();
417 drop_leftmost_labels(start, start_labels.saturating_sub(depth))
418}
419
420fn drop_leftmost_labels(domain: &str, n: usize) -> &str {
422 let mut suffix = domain;
423 for _ in 0..n {
424 match suffix.split_once('.') {
425 Some((_, rest)) => suffix = rest,
426 None => return "",
427 }
428 }
429 suffix
430}
431
432impl<'x> DmarcParameters<'x> {
433 pub fn new(
434 message: &'x AuthenticatedMessage<'x>,
435 dkim_output: &'x [DkimOutput<'x>],
436 rfc5321_mail_from_domain: &'x str,
437 spf_output: &'x SpfOutput,
438 ) -> Self {
439 Self {
440 message,
441 dkim_output,
442 dkim2_output: None,
443 rfc5321_mail_from_domain,
444 spf_output,
445 }
446 }
447
448 pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
449 self.dkim2_output = Some(dkim2_output);
450 self
451 }
452}
453
454impl<'x> From<DmarcParameters<'x>>
455 for Parameters<
456 'x,
457 DmarcParameters<'x>,
458 NoCache<Box<str>, Txt>,
459 NoCache<Box<str>, RecordSet<MX>>,
460 NoCache<Box<str>, RecordSet<Ipv4Addr>>,
461 NoCache<Box<str>, RecordSet<Ipv6Addr>>,
462 NoCache<IpAddr, RecordSet<Box<str>>>,
463 >
464{
465 fn from(params: DmarcParameters<'x>) -> Self {
466 Parameters::new(params)
467 }
468}
469
470#[cfg(test)]
471#[allow(unused)]
472mod test {
473 use super::DmarcParameters;
474 use crate::{
475 AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, DnsError, Error,
476 MessageAuthenticator, SpfOutput, SpfResult,
477 common::{cache::test::DummyCaches, parse::TxtRecordParser},
478 dkim::{DkimError, Signature},
479 dmarc::{Dmarc, Policy, URI},
480 };
481 use mail_parser::MessageParser;
482 use std::time::{Duration, Instant};
483
484 #[tokio::test]
485 async fn dmarc_verify_alignment() {
486 let resolver = MessageAuthenticator::new_system_conf().unwrap();
487 let caches = DummyCaches::new();
488
489 for (
490 dmarc_dns,
491 dmarc,
492 message,
493 rfc5321_mail_from_domain,
494 signature_domain,
495 dkim,
496 spf,
497 expect_dkim,
498 expect_spf,
499 policy,
500 ) in [
501 (
503 "_dmarc.example.org.",
504 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
505 "From: hello@example.org\r\n\r\n",
506 "example.org",
507 "example.org",
508 DkimResult::Pass,
509 SpfResult::Pass,
510 DmarcResult::Pass,
511 DmarcResult::Pass,
512 Policy::Reject,
513 ),
514 (
516 "_dmarc.example.org.",
517 "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
518 "From: hello@example.org\r\n\r\n",
519 "subdomain.example.org",
520 "subdomain.example.org",
521 DkimResult::Pass,
522 SpfResult::Pass,
523 DmarcResult::Pass,
524 DmarcResult::Pass,
525 Policy::Reject,
526 ),
527 (
529 "_dmarc.example.org.",
530 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
531 "From: hello@example.org\r\n\r\n",
532 "subdomain.example.org",
533 "subdomain.example.org",
534 DkimResult::Pass,
535 SpfResult::Pass,
536 DmarcResult::Fail(Error::NotAligned),
537 DmarcResult::Fail(Error::NotAligned),
538 Policy::Reject,
539 ),
540 (
542 "_dmarc.xn--eebajf.xn--9dbq2a.",
543 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
544 "From: hello@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\r\n\r\n",
545 "xn--eebajf.xn--9dbq2a",
546 "xn--eebajf.xn--9dbq2a",
547 DkimResult::Pass,
548 SpfResult::Pass,
549 DmarcResult::Pass,
550 DmarcResult::Pass,
551 Policy::Reject,
552 ),
553 (
555 "_dmarc.xn--eebajf.xn--9dbq2a.",
556 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@xn--eebajf.xn--9dbq2a",
557 "From: hello@xn--eebajf.xn--9dbq2a\r\n\r\n",
558 "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
559 "\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
560 DkimResult::Pass,
561 SpfResult::Pass,
562 DmarcResult::Pass,
563 DmarcResult::Pass,
564 Policy::Reject,
565 ),
566 (
568 "_dmarc.example.org.",
569 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
570 "From: hello@example.org\r\n\r\n",
571 "example.org",
572 "example.org",
573 DkimResult::Fail(Error::Dkim(DkimError::SignatureExpired)),
574 SpfResult::Fail,
575 DmarcResult::None,
576 DmarcResult::None,
577 Policy::Reject,
578 ),
579 ] {
580 caches.txt_add(
581 dmarc_dns,
582 Dmarc::parse(dmarc.as_bytes()).unwrap(),
583 Instant::now() + Duration::new(3200, 0),
584 );
585
586 let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
587 let signature = Signature {
588 d: signature_domain.into(),
589 ..Default::default()
590 };
591 let dkim = DkimOutput {
592 result: dkim,
593 signature: (&signature).into(),
594 report: None,
595 is_atps: false,
596 };
597 let spf = SpfOutput {
598 result: spf,
599 domain: rfc5321_mail_from_domain.to_string(),
600 report: None,
601 explanation: None,
602 };
603 let result = resolver
604 .verify_dmarc(caches.parameters(DmarcParameters::new(
605 &auth_message,
606 &[dkim],
607 rfc5321_mail_from_domain,
608 &spf,
609 )))
610 .await;
611 assert_eq!(result.dkim_result, expect_dkim, "dkim {message}");
612 assert_eq!(result.spf_result, expect_spf, "spf {message}");
613 assert_eq!(result.policy, policy, "policy {message}");
614 let expect_result =
615 if expect_dkim == DmarcResult::Pass || expect_spf == DmarcResult::Pass {
616 DmarcResult::Pass
617 } else {
618 DmarcResult::Fail(Error::NotAligned)
619 };
620 assert_eq!(result.result(), expect_result, "result {message}");
621 }
622 }
623
624 #[tokio::test]
625 async fn dmarc_policy_discovery() {
626 let resolver = MessageAuthenticator::new_system_conf().unwrap();
627 let expires = Instant::now() + Duration::new(3200, 0);
628
629 let caches = DummyCaches::new();
631 caches.txt_add(
632 "_dmarc.example.org.",
633 Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
634 expires,
635 );
636 assert_eq!(
637 policy_of(&resolver, &caches, "hello@example.org").await,
638 Policy::Reject,
639 );
640
641 let caches = DummyCaches::new();
644 caches.txt_add(
645 "_dmarc.example.org.",
646 Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
647 expires,
648 );
649 caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
650 assert_eq!(
651 policy_of(&resolver, &caches, "hello@sub.example.org").await,
652 Policy::Quarantine,
653 );
654
655 let caches = DummyCaches::new();
657 caches.txt_add(
658 "_dmarc.example.org.",
659 Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
660 expires,
661 );
662 assert_eq!(
663 policy_of(&resolver, &caches, "hello@ghost.example.org").await,
664 Policy::None,
665 );
666
667 let caches = DummyCaches::new();
669 caches.txt_add(
670 "_dmarc.example.org.",
671 Dmarc::parse(b"v=DMARC1; rua=mailto:d@example.org").unwrap(),
672 expires,
673 );
674 assert_eq!(
675 policy_of(&resolver, &caches, "hello@example.org").await,
676 Policy::None,
677 );
678
679 let caches = DummyCaches::new();
680 caches.txt_add(
681 "_dmarc.example.org.",
682 Dmarc::parse(b"v=DMARC1; sp=reject; rua=mailto:d@example.org").unwrap(),
683 expires,
684 );
685 caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
686 assert_eq!(
687 policy_of(&resolver, &caches, "hello@sub.example.org").await,
688 Policy::None,
689 );
690
691 let caches = DummyCaches::new();
692 caches.txt_add(
693 "_dmarc.example.org.",
694 Dmarc::parse(b"v=DMARC1; sp=reject; np=reject").unwrap(),
695 expires,
696 );
697 caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
698 let result = verify(&resolver, &caches, "hello@sub.example.org").await;
699 assert_eq!(result.dmarc_record(), None);
700 assert_eq!(result.result(), DmarcResult::None);
701
702 let caches = DummyCaches::new();
704 let result = verify(&resolver, &caches, "hello@nothing.example").await;
705 assert_eq!(result.dmarc_record(), None);
706 assert_eq!(result.result(), DmarcResult::None);
707 }
708
709 #[tokio::test]
710 async fn dmarc_verify_temp_errors() {
711 let resolver = MessageAuthenticator::new_system_conf().unwrap();
712 let caches = DummyCaches::new();
713 caches.txt_add(
714 "_dmarc.example.org.",
715 Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@example.org; ruf=mailto:f@example.org")
716 .unwrap(),
717 Instant::now() + Duration::new(3200, 0),
718 );
719 let dns_error = Error::Dns(DnsError::Resolver("timeout".to_string()));
720 let empty_dns_error = Error::Dns(DnsError::Resolver(String::new()));
721 let auth_message = AuthenticatedMessage::parse(b"From: hello@example.org\r\n\r\n").unwrap();
722
723 for (mail_from_domain, spf, signature_domain, dkim, expect_spf, expect_dkim, expect) in [
724 (
725 "example.org",
726 SpfResult::TempError,
727 "example.org",
728 DkimResult::Fail(Error::Dkim(DkimError::FailedBodyHashMatch)),
729 DmarcResult::TempError(empty_dns_error.clone()),
730 DmarcResult::None,
731 DmarcResult::TempError(empty_dns_error.clone()),
732 ),
733 (
734 "other.net",
735 SpfResult::TempError,
736 "other.net",
737 DkimResult::TempError(dns_error.clone()),
738 DmarcResult::None,
739 DmarcResult::None,
740 DmarcResult::Fail(Error::NotAligned),
741 ),
742 (
743 "other.net",
744 SpfResult::Fail,
745 "example.org",
746 DkimResult::TempError(dns_error.clone()),
747 DmarcResult::None,
748 DmarcResult::TempError(dns_error.clone()),
749 DmarcResult::TempError(dns_error.clone()),
750 ),
751 (
752 "example.org",
753 SpfResult::TempError,
754 "example.org",
755 DkimResult::Pass,
756 DmarcResult::TempError(empty_dns_error.clone()),
757 DmarcResult::Pass,
758 DmarcResult::Pass,
759 ),
760 (
761 "attacker._dns_error.net",
762 SpfResult::Pass,
763 "attacker._dns_error.net",
764 DkimResult::Pass,
765 DmarcResult::Fail(Error::NotAligned),
766 DmarcResult::Fail(Error::NotAligned),
767 DmarcResult::Fail(Error::NotAligned),
768 ),
769 (
770 "sub._dns_error.example.org",
771 SpfResult::Pass,
772 "other.net",
773 DkimResult::None,
774 DmarcResult::TempError(empty_dns_error.clone()),
775 DmarcResult::None,
776 DmarcResult::TempError(empty_dns_error.clone()),
777 ),
778 ] {
779 let signature = Signature {
780 d: signature_domain.into(),
781 ..Default::default()
782 };
783 let dkim = DkimOutput {
784 result: dkim,
785 signature: (&signature).into(),
786 report: None,
787 is_atps: false,
788 };
789 let spf = SpfOutput {
790 result: spf,
791 domain: mail_from_domain.to_string(),
792 report: None,
793 explanation: None,
794 };
795 let result = resolver
796 .verify_dmarc(caches.parameters(DmarcParameters::new(
797 &auth_message,
798 &[dkim],
799 mail_from_domain,
800 &spf,
801 )))
802 .await;
803 let case = format!("{mail_from_domain} {signature_domain}");
804 assert_eq!(result.spf_result, expect_spf, "spf {case}");
805 assert_eq!(result.dkim_result, expect_dkim, "dkim {case}");
806 assert_eq!(result.result(), expect, "result {case}");
807 assert_eq!(
808 result.failure_report().is_none(),
809 matches!(expect, DmarcResult::TempError(_) | DmarcResult::Pass),
810 "failure report {case}"
811 );
812 }
813 }
814
815 #[tokio::test]
816 async fn dmarc_tree_walk_psd() {
817 let resolver = MessageAuthenticator::new_system_conf().unwrap();
818 let expires = Instant::now() + Duration::new(3200, 0);
819
820 let caches = DummyCaches::new();
823 caches.txt_add(
824 "_dmarc.mail.example.com.",
825 Dmarc::parse(b"v=DMARC1; p=reject; psd=n; rua=mailto:d@example.com").unwrap(),
826 expires,
827 );
828 caches.ipv4_add("a.mail.example.com.", vec![[127, 0, 0, 1].into()], expires);
829 let result = verify_aligned(
830 &resolver,
831 &caches,
832 "hello@a.mail.example.com",
833 "b.mail.example.com",
834 )
835 .await;
836 assert_eq!(result.spf_result(), &DmarcResult::Pass);
837
838 let caches = DummyCaches::new();
842 caches.txt_add(
843 "_dmarc.bank.example.",
844 Dmarc::parse(b"v=DMARC1; p=reject; psd=y; rua=mailto:d@bank.example").unwrap(),
845 expires,
846 );
847 caches.txt_add(
848 "_dmarc.giant.bank.example.",
849 Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@giant.bank.example").unwrap(),
850 expires,
851 );
852 let result = verify_aligned(
853 &resolver,
854 &caches,
855 "hello@giant.bank.example",
856 "mega.bank.example",
857 )
858 .await;
859 assert_eq!(result.spf_result(), &DmarcResult::Fail(Error::NotAligned));
860 }
861
862 #[tokio::test]
863 async fn dmarc_tree_walk_query_cap() {
864 let resolver = MessageAuthenticator::new_system_conf().unwrap();
865 let expires = Instant::now() + Duration::new(3200, 0);
866
867 let caches = DummyCaches::new();
871 caches.txt_add(
872 "_dmarc.example.com.",
873 Dmarc::parse(b"v=DMARC1; p=reject; np=none; rua=mailto:d@example.com").unwrap(),
874 expires,
875 );
876 assert_eq!(
877 policy_of(
878 &resolver,
879 &caches,
880 "hello@a.b.c.d.e.f.g.h.i.j.mail.example.com",
881 )
882 .await,
883 Policy::None,
884 );
885 }
886
887 async fn verify(
888 resolver: &MessageAuthenticator,
889 caches: &DummyCaches,
890 from: &str,
891 ) -> DmarcOutputHelper {
892 verify_aligned(resolver, caches, from, "").await
893 }
894
895 async fn verify_aligned(
896 resolver: &MessageAuthenticator,
897 caches: &DummyCaches,
898 from: &str,
899 mail_from_domain: &str,
900 ) -> DmarcOutputHelper {
901 let message = format!("From: {from}\r\n\r\n");
902 let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
903 let spf = SpfOutput {
904 result: SpfResult::Pass,
905 domain: mail_from_domain.to_string(),
906 report: None,
907 explanation: None,
908 };
909 resolver
910 .verify_dmarc(caches.parameters(DmarcParameters::new(
911 &auth_message,
912 &[],
913 mail_from_domain,
914 &spf,
915 )))
916 .await
917 }
918
919 async fn policy_of(
920 resolver: &MessageAuthenticator,
921 caches: &DummyCaches,
922 from: &str,
923 ) -> Policy {
924 verify(resolver, caches, from).await.policy()
925 }
926
927 type DmarcOutputHelper = crate::DmarcOutput;
928
929 #[tokio::test]
930 async fn dmarc_verify_dkim2() {
931 use crate::Dkim2Result;
932 use crate::dkim2::{ChainLink, Dkim2Output, Signature as Dkim2Signature};
933
934 let resolver = MessageAuthenticator::new_system_conf().unwrap();
935 let caches = DummyCaches::new();
936
937 for (dmarc_dns, dmarc, message, signature_domain, dkim2_result, expect_dkim, policy) in [
938 (
940 "_dmarc.example.org.",
941 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
942 "From: hello@example.org\r\n\r\n",
943 "example.org",
944 Dkim2Result::Pass,
945 DmarcResult::Pass,
946 Policy::Reject,
947 ),
948 (
950 "_dmarc.example.org.",
951 "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
952 "From: hello@example.org\r\n\r\n",
953 "subdomain.example.org",
954 Dkim2Result::Pass,
955 DmarcResult::Pass,
956 Policy::Reject,
957 ),
958 (
960 "_dmarc.example.org.",
961 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
962 "From: hello@example.org\r\n\r\n",
963 "subdomain.example.org",
964 Dkim2Result::Pass,
965 DmarcResult::Fail(Error::NotAligned),
966 Policy::Reject,
967 ),
968 (
970 "_dmarc.example.org.",
971 "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
972 "From: hello@example.org\r\n\r\n",
973 "example.org",
974 Dkim2Result::Fail(Error::NotAligned),
975 DmarcResult::None,
976 Policy::Reject,
977 ),
978 ] {
979 caches.txt_add(
980 dmarc_dns,
981 Dmarc::parse(dmarc.as_bytes()).unwrap(),
982 Instant::now() + Duration::new(3200, 0),
983 );
984
985 let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
986 let signature = Dkim2Signature {
987 i: 1,
988 d: signature_domain.into(),
989 ..Default::default()
990 };
991 let dkim2 = Dkim2Output {
992 result: dkim2_result.clone(),
993 chain: vec![ChainLink {
994 signature: &signature,
995 instance: None,
996 result: dkim2_result,
997 custody_ok: true,
998 }],
999 };
1000 let spf = SpfOutput {
1001 result: SpfResult::None,
1002 domain: "example.org".to_string(),
1003 report: None,
1004 explanation: None,
1005 };
1006 let result = resolver
1007 .verify_dmarc(
1008 caches.parameters(
1009 DmarcParameters::new(&auth_message, &[], "example.org", &spf)
1010 .with_dkim2_output(&dkim2),
1011 ),
1012 )
1013 .await;
1014 assert_eq!(result.dkim_result, expect_dkim);
1015 assert_eq!(result.policy, policy);
1016 }
1017 }
1018
1019 #[tokio::test]
1020 async fn dmarc_verify_report_address() {
1021 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1022 let caches = DummyCaches::new().with_txt(
1023 "example.org._report._dmarc.external.org.",
1024 Dmarc::parse(b"v=DMARC1").unwrap(),
1025 Instant::now() + Duration::new(3200, 0),
1026 );
1027 let uris = vec![
1028 URI::new("dmarc@example.org", 0),
1029 URI::new("dmarc@external.org", 0),
1030 URI::new("domain@other.org", 0),
1031 ];
1032
1033 assert_eq!(
1034 resolver
1035 .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
1036 .await
1037 .unwrap(),
1038 vec![
1039 &URI::new("dmarc@example.org", 0),
1040 &URI::new("dmarc@external.org", 0),
1041 ]
1042 );
1043 }
1044
1045 #[tokio::test]
1046 async fn dmarc_verify_report_address_idn() {
1047 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1048 let caches = DummyCaches::new();
1049 let uris = vec![
1050 URI::new(
1051 "dmarc@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}",
1052 0,
1053 ),
1054 URI::new("dmarc@sub.xn--eebajf.xn--9dbq2a", 0),
1055 ];
1056
1057 assert_eq!(
1059 resolver
1060 .verify_dmarc_report_address("xn--eebajf.xn--9dbq2a", &uris, Some(&caches.txt))
1061 .await
1062 .unwrap(),
1063 uris.iter().collect::<Vec<_>>()
1064 );
1065 }
1066
1067 #[tokio::test]
1068 async fn dmarc_alignment_is_case_insensitive() {
1069 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1070 let caches = DummyCaches::new();
1071 caches.txt_add(
1072 "_dmarc.example.org.",
1073 Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; rua=mailto:d@example.org").unwrap(),
1074 Instant::now() + Duration::new(3200, 0),
1075 );
1076
1077 let result = verify_aligned(&resolver, &caches, "hello@example.org", "EXAMPLE.ORG").await;
1078 assert_eq!(result.spf_result(), &DmarcResult::Pass);
1079 }
1080}