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