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