1use 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 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 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 return DmarcOutput::default();
62 }
63 }
64 }
65 if rfc5322_from_domain.is_empty() {
66 return DmarcOutput::default();
67 }
68
69 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 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 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 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 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 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 {
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 (
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 (
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 (
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 (
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 (
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 (
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 (
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 (
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 (
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 (
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 (
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}