1use super::{
11 ChainBinding, Dkim2Result, Signature, Verification, sign::Envelope,
12 verify::relaxed_domain_match,
13};
14use crate::dns::DnsCache;
15use crate::{
16 AuthenticatedMessage, MessageAuthenticator, Parameters, ResolverCache, TxtRecord,
17 dkim2::sign::now, dns::NoCache,
18};
19use mail_parser::MessageParser;
20use std::marker::PhantomData;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Dkim2Dsn<'x, R = AuthenticatedMessage<'x>, S = AuthenticatedMessage<'x>>
29where
30 R: AsRef<AuthenticatedMessage<'x>>,
31 S: AsRef<AuthenticatedMessage<'x>>,
32{
33 pub dsn: R,
35 pub returned: S,
37 pub returned_full: bool,
41 _marker: PhantomData<&'x ()>,
42}
43
44impl<'x, R, S> Dkim2Dsn<'x, R, S>
45where
46 R: AsRef<AuthenticatedMessage<'x>>,
47 S: AsRef<AuthenticatedMessage<'x>>,
48{
49 pub fn new(dsn: R, returned: S, returned_full: bool) -> Self {
52 Dkim2Dsn {
53 dsn,
54 returned,
55 returned_full,
56 _marker: PhantomData,
57 }
58 }
59}
60
61impl<'x> Dkim2Dsn<'x> {
62 pub fn parse(raw_message: &'x [u8]) -> Result<Dkim2Dsn<'x>, Dkim2DsnFailure> {
73 let message = MessageParser::new()
74 .parse(raw_message)
75 .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
76 let root = message.root_part();
77 if !root.is_multipart() {
78 return Err(Dkim2DsnFailure::DsnUnparseable);
79 }
80
81 let mut returned = None;
82 for part in root.children() {
83 let slice = raw_message
84 .get(part.offset_body() as usize..part.offset_end() as usize)
85 .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
86 if part.is_content_type("message", "rfc822") {
87 returned = Some((slice, true));
88 } else if part.is_content_type("text", "rfc822-headers") {
89 returned = Some((slice, false));
90 }
91 }
92
93 let (returned_slice, returned_full) =
94 returned.ok_or(Dkim2DsnFailure::ReturnedUnparseable)?;
95 Ok(Dkim2Dsn {
96 dsn: AuthenticatedMessage::parse(raw_message).ok_or(Dkim2DsnFailure::DsnUnparseable)?,
97 returned: AuthenticatedMessage::parse(returned_slice)
98 .ok_or(Dkim2DsnFailure::ReturnedUnparseable)?,
99 returned_full,
100 _marker: PhantomData,
101 })
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Dkim2DsnOutput {
109 pub dsn: Dkim2Result,
111 pub returned: Dkim2Result,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Dkim2DsnFailure {
118 DsnUnparseable,
120 ReturnedUnparseable,
122 DsnNotSigned,
125 DsnChainFailed,
127 ReturnedNotSigned,
129 ReturnedChainFailed,
131 NotAligned,
134 TempError,
138}
139
140impl std::fmt::Display for Dkim2DsnFailure {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.write_str(match self {
143 Dkim2DsnFailure::DsnUnparseable => "DSN could not be parsed",
144 Dkim2DsnFailure::ReturnedUnparseable => "Returned message could not be parsed",
145 Dkim2DsnFailure::DsnNotSigned => "DSN is not DKIM2 signed",
146 Dkim2DsnFailure::DsnChainFailed => "DSN signature chain failed",
147 Dkim2DsnFailure::ReturnedNotSigned => "Returned message is not DKIM2 signed",
148 Dkim2DsnFailure::ReturnedChainFailed => "Returned message signature chain failed",
149 Dkim2DsnFailure::NotAligned => {
150 "DSN signer is not aligned with the returned message recipient"
151 }
152 Dkim2DsnFailure::TempError => {
153 "DKIM2 chain could not be verified due to a temporary DNS error"
154 }
155 })
156 }
157}
158
159fn top_signature<'x>(
160 message: &'x AuthenticatedMessage<'x>,
161) -> Option<(&'x String, &'x str, &'x [String])> {
162 message
163 .dkim2_signatures
164 .iter()
165 .map(|h| &h.header)
166 .max_by_key(|s| s.i)
167 .map(envelope_of)
168}
169
170fn envelope_of(signature: &Signature) -> (&String, &str, &[String]) {
171 match &signature.chain {
172 ChainBinding::Envelope { mail_from, rcpt_to } => {
173 (&signature.d, mail_from.as_str(), rcpt_to.as_slice())
174 }
175 ChainBinding::NextDomain(_) => (&signature.d, "", &[][..]),
176 }
177}
178
179fn domain_of(address: &str) -> &str {
180 let address = address.trim_start_matches('<').trim_end_matches('>');
181 address.rsplit_once('@').map(|(_, d)| d).unwrap_or(address)
182}
183
184fn is_dkim2_signed(message: &AuthenticatedMessage<'_>) -> bool {
185 !message.dkim2_signatures.is_empty() || message.has_dkim2_errors
186}
187
188impl AuthenticatedMessage<'_> {
189 pub fn dkim2_return_path(&self) -> Option<&str> {
198 dsn_return_path(self.dkim2_signatures.iter().map(|header| &header.header))
199 }
200}
201
202fn dsn_return_path<'x>(signatures: impl Iterator<Item = &'x Signature>) -> Option<&'x str> {
203 signatures
204 .max_by_key(|s| s.i)
205 .and_then(|top| match &top.chain {
206 ChainBinding::Envelope { mail_from, .. }
207 if !mail_from.is_empty() && mail_from != "<>" =>
208 {
209 Some(mail_from.as_str())
210 }
211 _ => None,
212 })
213}
214
215impl MessageAuthenticator {
216 pub async fn verify_dkim2_dsn<'x, R, S, C, A, RT>(
278 &self,
279 params: impl Into<Parameters<'x, &'x Dkim2Dsn<'x, R, S>, C>>,
280 envelope: Envelope<A, RT>,
281 ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
282 where
283 R: AsRef<AuthenticatedMessage<'x>> + 'x,
284 S: AsRef<AuthenticatedMessage<'x>> + 'x,
285 C: DnsCache + 'x,
286 A: AsRef<str> + Clone,
287 RT: IntoIterator<Item: AsRef<str>> + Clone,
288 {
289 let params = params.into();
290 self.verify_dkim2_dsn_(params.input, envelope, params.txt_cache(), now())
291 .await
292 }
293
294 pub(crate) async fn verify_dkim2_dsn_<'x, R, S, TXT, A, RT>(
295 &self,
296 dsn: &'x Dkim2Dsn<'x, R, S>,
297 envelope: Envelope<A, RT>,
298 cache_txt: Option<&TXT>,
299 now: u64,
300 ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
301 where
302 R: AsRef<AuthenticatedMessage<'x>> + 'x,
303 S: AsRef<AuthenticatedMessage<'x>> + 'x,
304 TXT: ResolverCache<Box<str>, TxtRecord>,
305 A: AsRef<str> + Clone,
306 RT: IntoIterator<Item: AsRef<str>> + Clone,
307 {
308 let dsn_message = dsn.dsn.as_ref();
309 if !is_dkim2_signed(dsn_message) {
310 return Err(Dkim2DsnFailure::DsnNotSigned);
311 } else if !is_dkim2_signed(dsn.returned.as_ref()) {
312 return Err(Dkim2DsnFailure::ReturnedNotSigned);
313 }
314
315 let dsn_output = self
316 .verify_dkim2_(
317 dsn_message,
318 envelope.clone(),
319 cache_txt,
320 now,
321 true,
322 Verification::Full,
323 )
324 .await;
325 let dsn_result = dsn_output.result;
326 match dsn_result {
327 Dkim2Result::Pass => {}
328 Dkim2Result::TempError(_) => return Err(Dkim2DsnFailure::TempError),
329 _ => return Err(Dkim2DsnFailure::DsnChainFailed),
330 }
331
332 let dsn_signing_domain = dsn_message
333 .dkim2_signatures
334 .iter()
335 .map(|h| &h.header)
336 .find(|signature| signature.i == 1)
337 .map(|signature| &signature.d);
338 let returned_top = top_signature(dsn.returned.as_ref());
339 let (returned_mail_from, returned_rcpt_to) = returned_top
340 .as_ref()
341 .map(|(_, mail_from, rcpt_to)| (*mail_from, *rcpt_to))
342 .unwrap_or_default();
343 let returned_result = self
344 .verify_dkim2_(
345 dsn.returned.as_ref(),
346 Envelope::new(returned_mail_from, returned_rcpt_to),
347 cache_txt,
348 now,
349 dsn.returned_full,
350 Verification::Full,
351 )
352 .await
353 .result;
354 match returned_result {
355 Dkim2Result::Pass => {}
356 Dkim2Result::TempError(_) => return Err(Dkim2DsnFailure::TempError),
357 _ => return Err(Dkim2DsnFailure::ReturnedChainFailed),
358 }
359
360 let aligned = match (&dsn_signing_domain, &returned_top) {
361 (Some(dsn_domain), Some((returned_domain, _, rcpt_to))) => {
362 let recipient_aligned = rcpt_to
363 .iter()
364 .any(|rcpt| relaxed_domain_match(domain_of(rcpt), dsn_domain));
365
366 let Envelope {
367 rcpt_to: envelope_rcpt_to,
368 ..
369 } = envelope;
370 let returned_is_ours = envelope_rcpt_to
371 .into_iter()
372 .any(|rcpt| relaxed_domain_match(domain_of(rcpt.as_ref()), returned_domain));
373
374 recipient_aligned && returned_is_ours
375 }
376 _ => false,
377 };
378
379 if aligned {
380 Ok(Dkim2DsnOutput {
381 dsn: dsn_result,
382 returned: returned_result,
383 })
384 } else {
385 Err(Dkim2DsnFailure::NotAligned)
386 }
387 }
388}
389
390impl<'x, R, S> From<&'x Dkim2Dsn<'x, R, S>> for Parameters<'x, &'x Dkim2Dsn<'x, R, S>, NoCache>
391where
392 R: AsRef<AuthenticatedMessage<'x>>,
393 S: AsRef<AuthenticatedMessage<'x>>,
394{
395 fn from(params: &'x Dkim2Dsn<'x, R, S>) -> Self {
396 Parameters::new(params)
397 }
398}
399
400#[cfg(test)]
401mod test {
402 use super::{Dkim2Dsn, Dkim2DsnFailure, Dkim2DsnOutput, Signature, dsn_return_path};
403 use crate::{
404 DnsError, Error, MessageAuthenticator,
405 crypto::Ed25519Key,
406 dkim::DomainKey,
407 dkim2::{ChainBinding, Dkim2Signer, Envelope, Hop},
408 dns::{TxtRecord, cache::test::DummyCaches},
409 parse::TxtRecordParser,
410 };
411 use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
412 use std::{
413 path::PathBuf,
414 time::{Duration, Instant},
415 };
416
417 const NOW: u64 = 1740002100;
418 const T: u64 = 1740000000;
419
420 fn resource(parts: &[&str]) -> PathBuf {
421 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
422 path.push("resources/dkim2");
423 for part in parts {
424 path.push(part);
425 }
426 path
427 }
428
429 fn load_key(domain: &str, selector: &str) -> Ed25519Key {
430 let pem = std::fs::read(resource(&[
431 "keys",
432 &format!("{selector}._domainkey.{domain}.pem"),
433 ]))
434 .unwrap();
435 let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
436 panic!("expected PKCS8 key");
437 };
438 Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
439 }
440
441 fn load_caches() -> DummyCaches {
442 let caches = DummyCaches::new();
443 let dns = std::fs::read(resource(&["dns.json"])).unwrap();
444 let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
445 let valid_until = Instant::now() + Duration::new(3600, 0);
446 for (domain, selectors) in dns.as_object().unwrap() {
447 for (selector, records) in selectors.as_object().unwrap() {
448 caches.txt_add(
449 format!("{selector}.{domain}."),
450 DomainKey::parse(records[0][1].as_str().unwrap().as_bytes()).unwrap(),
451 valid_until,
452 );
453 }
454 }
455 caches
456 }
457
458 fn sign_full<A, R, I>(
459 key: Ed25519Key,
460 domain: &str,
461 selector: &str,
462 message: &[u8],
463 hop: Hop<A, R, I>,
464 ) -> Vec<u8>
465 where
466 A: AsRef<str>,
467 R: IntoIterator<Item: AsRef<str>>,
468 I: Into<String>,
469 {
470 let signer = Dkim2Signer::from_key(key).domain(domain).selector(selector);
471 let signed = signer.sign_at(message, hop, None, T).unwrap();
472 let mut out = signed.to_header().into_bytes();
473 out.extend_from_slice(message);
474 out
475 }
476
477 const RETURNED_PLAIN: &str = concat!(
478 "From: sender@test1.dkim2.com\r\n",
479 "To: user@test2.dkim2.com\r\n",
480 "Subject: Hello\r\n",
481 "Date: Sat, 01 Mar 2026 12:00:00 +0000\r\n",
482 "Message-ID: <m@test1.dkim2.com>\r\n",
483 "\r\n",
484 "This is the original body.\r\n",
485 );
486
487 fn signed_returned() -> Vec<u8> {
488 sign_full(
489 load_key("test1.dkim2.com", "ed25519"),
490 "test1.dkim2.com",
491 "ed25519",
492 RETURNED_PLAIN.as_bytes(),
493 Hop::real("sender@test1.dkim2.com", ["user@test2.dkim2.com"]),
494 )
495 }
496
497 fn headers_only(message: &[u8]) -> Vec<u8> {
498 let end = message.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
499 message[..end].to_vec()
500 }
501
502 fn make_dsn(returned: &[u8], returned_ct: &str, dsn_signed: bool) -> Vec<u8> {
503 let mut body = Vec::new();
504 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: text/plain\r\n\r\n");
505 body.extend_from_slice(b"Delivery to user@test2.dkim2.com failed.\r\n");
506 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/delivery-status\r\n\r\n");
507 body.extend_from_slice(b"Reporting-MTA: dns; test2.dkim2.com\r\n\r\n");
508 body.extend_from_slice(b"Final-Recipient: rfc822; user@test2.dkim2.com\r\n");
509 body.extend_from_slice(b"Action: failed\r\nStatus: 5.1.1\r\n");
510 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: ");
511 body.extend_from_slice(returned_ct.as_bytes());
512 body.extend_from_slice(b"\r\n\r\n");
513 body.extend_from_slice(returned);
514 body.extend_from_slice(b"\r\n--BOUNDARY--\r\n");
515
516 let mut dsn_plain = Vec::new();
517 dsn_plain.extend_from_slice(b"From: postmaster@test2.dkim2.com\r\n");
518 dsn_plain.extend_from_slice(b"To: sender@test1.dkim2.com\r\n");
519 dsn_plain.extend_from_slice(b"Subject: Delivery Status Notification (Failure)\r\n");
520 dsn_plain.extend_from_slice(b"Date: Sat, 01 Mar 2026 12:05:00 +0000\r\n");
521 dsn_plain.extend_from_slice(
522 b"Content-Type: multipart/report; report-type=delivery-status; boundary=\"BOUNDARY\"\r\n",
523 );
524 dsn_plain.extend_from_slice(b"\r\n");
525 dsn_plain.extend_from_slice(&body);
526
527 if dsn_signed {
528 sign_full(
529 load_key("test2.dkim2.com", "ed25519"),
530 "test2.dkim2.com",
531 "ed25519",
532 &dsn_plain,
533 Hop::real("<>", ["sender@test1.dkim2.com"]),
534 )
535 } else {
536 dsn_plain
537 }
538 }
539
540 async fn verify(dsn_bytes: &[u8]) -> Result<Dkim2DsnOutput, Dkim2DsnFailure> {
541 verify_with(dsn_bytes, load_caches()).await
542 }
543
544 async fn verify_with(
545 dsn_bytes: &[u8],
546 caches: DummyCaches,
547 ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure> {
548 let resolver = MessageAuthenticator::new_system_conf().unwrap();
549 let dsn = Dkim2Dsn::parse(dsn_bytes).expect("parse DSN");
550 let params = caches.parameters(&dsn);
551 let envelope = Envelope::new("<>", ["sender@test1.dkim2.com"]);
552 resolver
553 .verify_dkim2_dsn_(&dsn, envelope, params.txt_cache(), NOW)
554 .await
555 }
556
557 #[test]
558 fn dsn_return_path_null() {
559 let mut signature = Signature {
560 i: 1,
561 chain: ChainBinding::Envelope {
562 mail_from: "<>".to_string(),
563 rcpt_to: vec!["recipient@example.com".to_string()],
564 },
565 ..Default::default()
566 };
567 assert_eq!(dsn_return_path(std::iter::once(&signature)), None);
568
569 signature.chain = ChainBinding::Envelope {
570 mail_from: "sender@test1.dkim2.com".to_string(),
571 rcpt_to: vec!["recipient@example.com".to_string()],
572 };
573 assert_eq!(
574 dsn_return_path(std::iter::once(&signature)),
575 Some("sender@test1.dkim2.com")
576 );
577 }
578
579 #[tokio::test]
580 async fn verify_dsn_round_trip() {
581 let dsn_signed = make_dsn(&signed_returned(), "message/rfc822", true);
582 assert!(Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
583
584 let output = verify(&dsn_signed).await;
585 assert!(output.is_ok(), "{output:?}");
586 }
587
588 #[tokio::test]
590 async fn verify_dsn_with_two_signatures() {
591 let plain = make_dsn(&signed_returned(), "message/rfc822", false);
592 let hop1 = sign_full(
593 load_key("test2.dkim2.com", "ed25519"),
594 "test2.dkim2.com",
595 "ed25519",
596 &plain,
597 Hop::real("<>", ["relay@test3.dkim2.com"]),
598 );
599 let signed = Dkim2Signer::from_key(load_key("test3.dkim2.com", "ed25519"))
600 .domain("test3.dkim2.com")
601 .selector("ed25519")
602 .out_of_band_arrangement()
603 .sign_at(
604 hop1.as_slice(),
605 Hop::real("<>", ["sender@test1.dkim2.com"]),
606 None,
607 T,
608 )
609 .unwrap();
610 let mut hop2 = signed.to_header().into_bytes();
611 hop2.extend_from_slice(&hop1);
612
613 let output = verify(&hop2).await;
614 assert!(output.is_ok(), "{output:?}");
615 }
616
617 #[tokio::test]
618 async fn verify_dsn_returned_headers_only() {
619 let dsn_signed = make_dsn(
620 &headers_only(&signed_returned()),
621 "text/rfc822-headers",
622 true,
623 );
624 assert!(!Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
625
626 let output = verify(&dsn_signed).await;
627 assert!(output.is_ok(), "{output:?}");
628 }
629
630 #[tokio::test]
631 async fn verify_dsn_returned_not_signed() {
632 let dsn_signed = make_dsn(RETURNED_PLAIN.as_bytes(), "message/rfc822", true);
633
634 let output = verify(&dsn_signed).await;
635 assert_eq!(output, Err(Dkim2DsnFailure::ReturnedNotSigned));
636 }
637
638 #[tokio::test]
640 async fn verify_dsn_temporary_dns_failure() {
641 let dsn_signed = make_dsn(&signed_returned(), "message/rfc822", true);
642 for domain in ["test2.dkim2.com", "test1.dkim2.com"] {
643 let caches = load_caches();
644 caches.txt_add(
645 format!("ed25519._domainkey.{domain}."),
646 TxtRecord::Error(Error::Dns(DnsError::Resolver("timeout".to_string()))),
647 Instant::now() + Duration::new(3600, 0),
648 );
649 assert_eq!(
650 verify_with(&dsn_signed, caches).await,
651 Err(Dkim2DsnFailure::TempError),
652 "{domain}"
653 );
654 }
655 }
656
657 #[tokio::test]
658 async fn verify_dsn_not_signed() {
659 let dsn_unsigned = make_dsn(&signed_returned(), "message/rfc822", false);
660
661 let output = verify(&dsn_unsigned).await;
662 assert_eq!(output, Err(Dkim2DsnFailure::DsnNotSigned));
663 }
664}