1use super::{ChainBinding, Dkim2Result, Signature, sign::Envelope, verify::relaxed_domain_match};
8use crate::{
9 AuthenticatedMessage, MX, MessageAuthenticator, Parameters, RecordSet, ResolverCache, Txt,
10 dkim2::sign::now,
11};
12use mail_parser::{MessageParser, MimeHeaders, PartType};
13use std::marker::PhantomData;
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Dkim2Dsn<'x, R = AuthenticatedMessage<'x>, S = AuthenticatedMessage<'x>>
18where
19 R: AsRef<AuthenticatedMessage<'x>>,
20 S: AsRef<AuthenticatedMessage<'x>>,
21{
22 pub raw: R,
23 pub returned: S,
24 pub returned_full: bool,
25 _marker: PhantomData<&'x ()>,
26}
27
28impl<'x, R, S> Dkim2Dsn<'x, R, S>
29where
30 R: AsRef<AuthenticatedMessage<'x>>,
31 S: AsRef<AuthenticatedMessage<'x>>,
32{
33 pub fn new(raw: R, returned: S, returned_full: bool) -> Self {
35 Dkim2Dsn {
36 raw,
37 returned,
38 returned_full,
39 _marker: PhantomData,
40 }
41 }
42}
43
44impl<'x> Dkim2Dsn<'x> {
45 pub fn parse(raw_message: &'x [u8]) -> Result<Dkim2Dsn<'x>, Dkim2DsnFailure> {
48 let message = MessageParser::new()
49 .parse(raw_message)
50 .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
51 let PartType::Multipart(children) = &message.root_part().body else {
52 return Err(Dkim2DsnFailure::DsnUnparseable);
53 };
54
55 let mut returned = None;
56 for child in children {
57 let part = message
58 .parts
59 .get(*child as usize)
60 .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
61 let slice = raw_message
62 .get(part.offset_body as usize..part.offset_end as usize)
63 .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
64 if part.is_content_type("message", "rfc822") {
65 returned = Some((slice, true));
66 } else if part.is_content_type("text", "rfc822-headers") {
67 returned = Some((slice, false));
68 }
69 }
70
71 let (returned_slice, returned_full) =
72 returned.ok_or(Dkim2DsnFailure::ReturnedUnparseable)?;
73 Ok(Dkim2Dsn {
74 raw: AuthenticatedMessage::parse(raw_message).ok_or(Dkim2DsnFailure::DsnUnparseable)?,
75 returned: AuthenticatedMessage::parse(returned_slice)
76 .ok_or(Dkim2DsnFailure::ReturnedUnparseable)?,
77 returned_full,
78 _marker: PhantomData,
79 })
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Dkim2DsnOutput {
85 pub dsn: Dkim2Result,
86 pub returned: Dkim2Result,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum Dkim2DsnFailure {
92 DsnUnparseable,
93 ReturnedUnparseable,
94 DsnNotSigned,
95 DsnChainFailed,
96 ReturnedNotSigned,
97 ReturnedChainFailed,
98 NotAligned,
99}
100
101impl std::fmt::Display for Dkim2DsnFailure {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.write_str(match self {
104 Dkim2DsnFailure::DsnUnparseable => "DSN could not be parsed",
105 Dkim2DsnFailure::ReturnedUnparseable => "Returned message could not be parsed",
106 Dkim2DsnFailure::DsnNotSigned => "DSN is not DKIM2 signed",
107 Dkim2DsnFailure::DsnChainFailed => "DSN signature chain failed",
108 Dkim2DsnFailure::ReturnedNotSigned => "Returned message is not DKIM2 signed",
109 Dkim2DsnFailure::ReturnedChainFailed => "Returned message signature chain failed",
110 Dkim2DsnFailure::NotAligned => {
111 "DSN signer is not aligned with the returned message recipient"
112 }
113 })
114 }
115}
116
117fn top_signature<'x>(
118 message: &'x AuthenticatedMessage<'x>,
119) -> Option<(&'x String, &'x str, &'x [String])> {
120 message
121 .dkim2_signatures
122 .iter()
123 .map(|h| &h.header)
124 .max_by_key(|s| s.i)
125 .map(|top| match &top.chain {
126 ChainBinding::Envelope { mail_from, rcpt_to } => {
127 (&top.d, mail_from.as_str(), rcpt_to.as_slice())
128 }
129 ChainBinding::NextDomain(_) => (&top.d, "", &[][..]),
130 })
131}
132
133fn domain_of(address: &str) -> &str {
134 let address = address.trim_start_matches('<').trim_end_matches('>');
135 address.rsplit_once('@').map(|(_, d)| d).unwrap_or(address)
136}
137
138fn is_dkim2_signed(message: &AuthenticatedMessage<'_>) -> bool {
139 !message.dkim2_signatures.is_empty() || message.has_dkim2_errors
140}
141
142impl Signature {
143 pub fn dsn_return_path(signatures: &[Signature]) -> Option<&str> {
145 signatures
146 .iter()
147 .max_by_key(|s| s.i)
148 .and_then(|top| match &top.chain {
149 ChainBinding::Envelope { mail_from, .. }
150 if !mail_from.is_empty() && mail_from != "<>" =>
151 {
152 Some(mail_from.as_str())
153 }
154 _ => None,
155 })
156 }
157}
158
159impl MessageAuthenticator {
160 pub async fn verify_dkim2_dsn<'x, R, S, TXT, MXX, IPV4, IPV6, PTR, A, RT>(
162 &self,
163 params: impl Into<Parameters<'x, &'x Dkim2Dsn<'x, R, S>, TXT, MXX, IPV4, IPV6, PTR>>,
164 envelope: Envelope<A, RT>,
165 ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
166 where
167 R: AsRef<AuthenticatedMessage<'x>> + 'x,
168 S: AsRef<AuthenticatedMessage<'x>> + 'x,
169 TXT: ResolverCache<Box<str>, Txt> + 'x,
170 MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
171 IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
172 IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
173 PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
174 A: AsRef<str> + Clone,
175 RT: IntoIterator<Item: AsRef<str>> + Clone,
176 {
177 let params = params.into();
178 self.verify_dkim2_dsn_(params.params, envelope, params.cache_txt, now())
179 .await
180 }
181
182 pub(crate) async fn verify_dkim2_dsn_<'x, R, S, TXT, A, RT>(
183 &self,
184 dsn: &'x Dkim2Dsn<'x, R, S>,
185 envelope: Envelope<A, RT>,
186 cache_txt: Option<&TXT>,
187 now: u64,
188 ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
189 where
190 R: AsRef<AuthenticatedMessage<'x>> + 'x,
191 S: AsRef<AuthenticatedMessage<'x>> + 'x,
192 TXT: ResolverCache<Box<str>, Txt>,
193 A: AsRef<str> + Clone,
194 RT: IntoIterator<Item: AsRef<str>> + Clone,
195 {
196 if !is_dkim2_signed(dsn.raw.as_ref()) {
197 return Err(Dkim2DsnFailure::DsnNotSigned);
198 } else if !is_dkim2_signed(dsn.returned.as_ref()) {
199 return Err(Dkim2DsnFailure::ReturnedNotSigned);
200 }
201
202 let dsn_output = self
203 .verify_dkim2_(dsn.raw.as_ref(), envelope.clone(), cache_txt, now, true)
204 .await;
205 let dsn_result = dsn_output.result;
206 if !matches!(dsn_result, Dkim2Result::Pass) {
207 return Err(Dkim2DsnFailure::DsnChainFailed);
208 }
209
210 let dsn_signing_domain = top_signature(dsn.raw.as_ref()).map(|(d, _, _)| d);
211 let returned_top = top_signature(dsn.returned.as_ref());
212 let (returned_mail_from, returned_rcpt_to) = returned_top
213 .as_ref()
214 .map(|(_, mail_from, rcpt_to)| (*mail_from, *rcpt_to))
215 .unwrap_or_default();
216 let returned_result = self
217 .verify_dkim2_(
218 dsn.returned.as_ref(),
219 Envelope::new(returned_mail_from, returned_rcpt_to),
220 cache_txt,
221 now,
222 dsn.returned_full,
223 )
224 .await
225 .result;
226 if !matches!(returned_result, Dkim2Result::Pass) {
227 return Err(Dkim2DsnFailure::ReturnedChainFailed);
228 };
229
230 let aligned = match (&dsn_signing_domain, &returned_top) {
231 (Some(dsn_domain), Some((returned_domain, _, rcpt_to))) => {
232 let recipient_aligned = rcpt_to
235 .iter()
236 .any(|rcpt| relaxed_domain_match(domain_of(rcpt), dsn_domain));
237
238 let Envelope {
241 rcpt_to: envelope_rcpt_to,
242 ..
243 } = envelope;
244 let returned_is_ours = envelope_rcpt_to
245 .into_iter()
246 .any(|rcpt| relaxed_domain_match(domain_of(rcpt.as_ref()), returned_domain));
247
248 recipient_aligned && returned_is_ours
249 }
250 _ => false,
251 };
252
253 if aligned {
254 Ok(Dkim2DsnOutput {
255 dsn: dsn_result,
256 returned: returned_result,
257 })
258 } else {
259 Err(Dkim2DsnFailure::NotAligned)
260 }
261 }
262}
263
264#[cfg(test)]
265mod test {
266 use super::{Dkim2Dsn, Dkim2DsnFailure, Dkim2DsnOutput, Signature};
267 use crate::{
268 MessageAuthenticator,
269 common::{
270 cache::test::DummyCaches, crypto::Ed25519Key, parse::TxtRecordParser, verify::DomainKey,
271 },
272 dkim2::{ChainBinding, Dkim2Signer, Envelope, Hop},
273 };
274 use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
275 use std::{
276 path::PathBuf,
277 time::{Duration, Instant},
278 };
279
280 const NOW: u64 = 1740002100;
281 const T: u64 = 1740000000;
282
283 fn resource(parts: &[&str]) -> PathBuf {
284 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
285 path.push("resources/dkim2");
286 for part in parts {
287 path.push(part);
288 }
289 path
290 }
291
292 fn load_key(domain: &str, selector: &str) -> Ed25519Key {
293 let pem = std::fs::read(resource(&[
294 "keys",
295 &format!("{selector}._domainkey.{domain}.pem"),
296 ]))
297 .unwrap();
298 let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
299 panic!("expected PKCS8 key");
300 };
301 Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
302 }
303
304 fn load_caches() -> DummyCaches {
305 let caches = DummyCaches::new();
306 let dns = std::fs::read(resource(&["dns.json"])).unwrap();
307 let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
308 let valid_until = Instant::now() + Duration::new(3600, 0);
309 for (domain, selectors) in dns.as_object().unwrap() {
310 for (selector, records) in selectors.as_object().unwrap() {
311 caches.txt_add(
312 format!("{selector}.{domain}."),
313 DomainKey::parse(records[0][1].as_str().unwrap().as_bytes()).unwrap(),
314 valid_until,
315 );
316 }
317 }
318 caches
319 }
320
321 fn sign_full<A, R, I>(
322 key: Ed25519Key,
323 domain: &str,
324 selector: &str,
325 message: &[u8],
326 hop: Hop<A, R, I>,
327 ) -> Vec<u8>
328 where
329 A: AsRef<str>,
330 R: IntoIterator<Item: AsRef<str>>,
331 I: Into<String>,
332 {
333 let signer = Dkim2Signer::from_key(key).domain(domain).selector(selector);
334 let signed = signer.sign_at(message, hop, T).unwrap();
335 let mut out = signed.to_header().into_bytes();
336 out.extend_from_slice(message);
337 out
338 }
339
340 const RETURNED_PLAIN: &str = concat!(
341 "From: sender@test1.dkim2.com\r\n",
342 "To: user@test2.dkim2.com\r\n",
343 "Subject: Hello\r\n",
344 "Date: Sat, 01 Mar 2026 12:00:00 +0000\r\n",
345 "Message-ID: <m@test1.dkim2.com>\r\n",
346 "\r\n",
347 "This is the original body.\r\n",
348 );
349
350 fn signed_returned() -> Vec<u8> {
351 sign_full(
352 load_key("test1.dkim2.com", "ed25519"),
353 "test1.dkim2.com",
354 "ed25519",
355 RETURNED_PLAIN.as_bytes(),
356 Hop::real("sender@test1.dkim2.com", ["user@test2.dkim2.com"]),
357 )
358 }
359
360 fn headers_only(message: &[u8]) -> Vec<u8> {
361 let end = message.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
362 message[..end].to_vec()
363 }
364
365 fn make_dsn(returned: &[u8], returned_ct: &str, dsn_signed: bool) -> Vec<u8> {
366 let mut body = Vec::new();
367 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: text/plain\r\n\r\n");
368 body.extend_from_slice(b"Delivery to user@test2.dkim2.com failed.\r\n");
369 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/delivery-status\r\n\r\n");
370 body.extend_from_slice(b"Reporting-MTA: dns; test2.dkim2.com\r\n\r\n");
371 body.extend_from_slice(b"Final-Recipient: rfc822; user@test2.dkim2.com\r\n");
372 body.extend_from_slice(b"Action: failed\r\nStatus: 5.1.1\r\n");
373 body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: ");
374 body.extend_from_slice(returned_ct.as_bytes());
375 body.extend_from_slice(b"\r\n\r\n");
376 body.extend_from_slice(returned);
377 body.extend_from_slice(b"\r\n--BOUNDARY--\r\n");
378
379 let mut dsn_plain = Vec::new();
380 dsn_plain.extend_from_slice(b"From: postmaster@test2.dkim2.com\r\n");
381 dsn_plain.extend_from_slice(b"To: sender@test1.dkim2.com\r\n");
382 dsn_plain.extend_from_slice(b"Subject: Delivery Status Notification (Failure)\r\n");
383 dsn_plain.extend_from_slice(b"Date: Sat, 01 Mar 2026 12:05:00 +0000\r\n");
384 dsn_plain.extend_from_slice(
385 b"Content-Type: multipart/report; report-type=delivery-status; boundary=\"BOUNDARY\"\r\n",
386 );
387 dsn_plain.extend_from_slice(b"\r\n");
388 dsn_plain.extend_from_slice(&body);
389
390 if dsn_signed {
391 sign_full(
392 load_key("test2.dkim2.com", "ed25519"),
393 "test2.dkim2.com",
394 "ed25519",
395 &dsn_plain,
396 Hop::real("<>", ["sender@test1.dkim2.com"]),
397 )
398 } else {
399 dsn_plain
400 }
401 }
402
403 async fn verify(dsn_bytes: &[u8]) -> Result<Dkim2DsnOutput, Dkim2DsnFailure> {
404 let resolver = MessageAuthenticator::new_system_conf().unwrap();
405 let caches = load_caches();
406 let dsn = Dkim2Dsn::parse(dsn_bytes).expect("parse DSN");
407 let params = caches.parameters(&dsn);
408 let envelope = Envelope::new("<>", ["sender@test1.dkim2.com"]);
409 resolver
410 .verify_dkim2_dsn_(&dsn, envelope, params.cache_txt, NOW)
411 .await
412 }
413
414 #[test]
415 fn dsn_return_path_null() {
416 let mut signature = Signature {
417 i: 1,
418 chain: ChainBinding::Envelope {
419 mail_from: "<>".to_string(),
420 rcpt_to: vec!["recipient@example.com".to_string()],
421 },
422 ..Default::default()
423 };
424 assert_eq!(
425 Signature::dsn_return_path(std::slice::from_ref(&signature)),
426 None
427 );
428
429 signature.chain = ChainBinding::Envelope {
430 mail_from: "sender@test1.dkim2.com".to_string(),
431 rcpt_to: vec!["recipient@example.com".to_string()],
432 };
433 assert_eq!(
434 Signature::dsn_return_path(std::slice::from_ref(&signature)),
435 Some("sender@test1.dkim2.com")
436 );
437 }
438
439 #[tokio::test]
440 async fn verify_dsn_round_trip() {
441 let dsn_signed = make_dsn(&signed_returned(), "message/rfc822", true);
442 assert!(Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
443
444 let output = verify(&dsn_signed).await;
445 assert!(output.is_ok(), "{output:?}");
446 }
447
448 #[tokio::test]
449 async fn verify_dsn_returned_headers_only() {
450 let dsn_signed = make_dsn(
451 &headers_only(&signed_returned()),
452 "text/rfc822-headers",
453 true,
454 );
455 assert!(!Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
456
457 let output = verify(&dsn_signed).await;
458 assert!(output.is_ok(), "{output:?}");
459 }
460
461 #[tokio::test]
462 async fn verify_dsn_returned_not_signed() {
463 let dsn_signed = make_dsn(RETURNED_PLAIN.as_bytes(), "message/rfc822", true);
464
465 let output = verify(&dsn_signed).await;
466 assert_eq!(output, Err(Dkim2DsnFailure::ReturnedNotSigned));
467 }
468
469 #[tokio::test]
470 async fn verify_dsn_not_signed() {
471 let dsn_unsigned = make_dsn(&signed_returned(), "message/rfc822", false);
472
473 let output = verify(&dsn_unsigned).await;
474 assert_eq!(output, Err(Dkim2DsnFailure::DsnNotSigned));
475 }
476}