Skip to main content

mail_auth/dkim2/
dsn.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use 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    /// Creates a new Dkim2Dsn from the raw DSN and the returned message
34    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    /// Parses a multipart/report DSN and locates the embedded returned message
46    /// (message/rfc822 or text/rfc822-headers).
47    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/// Why an inbound DSN failed authentication
90#[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    /// Returns the address a DSN for this message must be returned to
144    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    /// Authenticates an inbound DKIM2-signed DSN
161    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                // 12.1.2(1): the DSN signer is aligned with the recipient
233                // recorded in the rt= tag of the returned message's top signature.
234                let recipient_aligned = rcpt_to
235                    .iter()
236                    .any(|rcpt| relaxed_domain_match(domain_of(rcpt), dsn_domain));
237
238                // 12.1.2(2): the returned message's top signature was generated
239                // by us, the system receiving the DSN.
240                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}