1use 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
22pub struct DmarcParameters<'x> {
29 pub message: &'x AuthenticatedMessage<'x>,
31 pub dkim_output: &'x [DkimOutput<'x>],
33 pub dkim2_output: Option<&'x Dkim2Output<'x>>,
36 pub mail_from_domain: &'x str,
39 pub spf_output: &'x SpfOutput,
41}
42
43impl MessageAuthenticator {
44 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 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 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 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 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
487fn 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
504fn 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
511fn 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 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 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;