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
7//! DKIM2 delivery status notifications: inbound authentication (§16.4) and
8//! outbound signing domain and returned message helpers (§16.2, §16.3).
9
10use super::{
11    ChainBinding, Dkim2Result, Recipe, Signature, Verification, sign::Envelope,
12    verify::relaxed_domain_match,
13};
14use crate::dns::DnsCache;
15use crate::headers::HeaderIterator;
16use crate::{
17    AuthenticatedMessage, MessageAuthenticator, Parameters, ResolverCache, TxtRecord,
18    dkim2::sign::now, dns::NoCache,
19};
20use mail_parser::MessageParser;
21use std::marker::PhantomData;
22
23/// An inbound delivery status notification (DSN) and the message it returns.
24///
25/// Input of [`MessageAuthenticator::verify_dkim2_dsn`]. Build it with
26/// [`Dkim2Dsn::parse`] from a raw `multipart/report` DSN, or with
27/// [`Dkim2Dsn::new`] from messages parsed by the caller.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Dkim2Dsn<'x, R = AuthenticatedMessage<'x>, S = AuthenticatedMessage<'x>>
30where
31    R: AsRef<AuthenticatedMessage<'x>>,
32    S: AsRef<AuthenticatedMessage<'x>>,
33{
34    /// The DSN message itself, including its own DKIM2 header fields.
35    pub dsn: R,
36    /// The returned message embedded in the DSN.
37    pub returned: S,
38    /// Whether `returned` includes the body (`message/rfc822`) or only the
39    /// header fields (`text/rfc822-headers`). Without the body, body hashes
40    /// are not checked.
41    pub returned_full: bool,
42    _marker: PhantomData<&'x ()>,
43}
44
45impl<'x, R, S> Dkim2Dsn<'x, R, S>
46where
47    R: AsRef<AuthenticatedMessage<'x>>,
48    S: AsRef<AuthenticatedMessage<'x>>,
49{
50    /// Creates a `Dkim2Dsn` from the parsed DSN, the parsed returned message
51    /// and whether the returned message includes its body.
52    pub fn new(dsn: R, returned: S, returned_full: bool) -> Self {
53        Dkim2Dsn {
54            dsn,
55            returned,
56            returned_full,
57            _marker: PhantomData,
58        }
59    }
60}
61
62impl<'x> Dkim2Dsn<'x> {
63    /// Parses a `multipart/report` DSN and locates the embedded returned
64    /// message, a `message/rfc822` or `text/rfc822-headers` part. If several
65    /// parts qualify, the last one is used.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Dkim2DsnFailure::DsnUnparseable`] if `raw_message` is not a
70    /// parseable multipart message, and
71    /// [`Dkim2DsnFailure::ReturnedUnparseable`] if it has no returned message
72    /// part or that part cannot be parsed.
73    pub fn parse(raw_message: &'x [u8]) -> Result<Dkim2Dsn<'x>, Dkim2DsnFailure> {
74        let message = MessageParser::new()
75            .parse(raw_message)
76            .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
77        let root = message.root_part();
78        if !root.is_multipart() {
79            return Err(Dkim2DsnFailure::DsnUnparseable);
80        }
81
82        let mut returned = None;
83        for part in root.children() {
84            let slice = raw_message
85                .get(part.offset_body() as usize..part.offset_end() as usize)
86                .ok_or(Dkim2DsnFailure::DsnUnparseable)?;
87            if part.is_content_type("message", "rfc822") {
88                returned = Some((slice, true));
89            } else if part.is_content_type("text", "rfc822-headers") {
90                returned = Some((slice, false));
91            }
92        }
93
94        let (returned_slice, returned_full) =
95            returned.ok_or(Dkim2DsnFailure::ReturnedUnparseable)?;
96        Ok(Dkim2Dsn {
97            dsn: AuthenticatedMessage::parse(raw_message).ok_or(Dkim2DsnFailure::DsnUnparseable)?,
98            returned: AuthenticatedMessage::parse(returned_slice)
99                .ok_or(Dkim2DsnFailure::ReturnedUnparseable)?,
100            returned_full,
101            _marker: PhantomData,
102        })
103    }
104}
105
106/// Result of a successful
107/// [`MessageAuthenticator::verify_dkim2_dsn`].
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Dkim2DsnOutput {
110    /// Verification result of the DSN's own DKIM2 chain.
111    pub dsn: Dkim2Result,
112    /// Verification result of the returned message's DKIM2 chain.
113    pub returned: Dkim2Result,
114}
115
116/// Reason an inbound DSN failed parsing or authentication.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Dkim2DsnFailure {
119    /// The DSN is not a parseable multipart message.
120    DsnUnparseable,
121    /// The DSN has no returned message part, or it cannot be parsed.
122    ReturnedUnparseable,
123    /// The DSN has no `DKIM2-Signature` header field, not even a malformed
124    /// one.
125    DsnNotSigned,
126    /// The DSN's DKIM2 chain did not pass verification.
127    DsnChainFailed,
128    /// The returned message has no `DKIM2-Signature` header field.
129    ReturnedNotSigned,
130    /// The returned message's DKIM2 chain did not pass verification.
131    ReturnedChainFailed,
132    /// The DSN signer is not a recipient of the returned message, or the
133    /// returned message was not last signed by the receiving system.
134    NotAligned,
135    /// A DKIM2 chain of the DSN or of the returned message could not be
136    /// verified because of a temporary DNS error (§13.2); the DSN should be
137    /// rejected temporarily so that the sender retries.
138    TempError,
139}
140
141impl std::fmt::Display for Dkim2DsnFailure {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.write_str(match self {
144            Dkim2DsnFailure::DsnUnparseable => "DSN could not be parsed",
145            Dkim2DsnFailure::ReturnedUnparseable => "Returned message could not be parsed",
146            Dkim2DsnFailure::DsnNotSigned => "DSN is not DKIM2 signed",
147            Dkim2DsnFailure::DsnChainFailed => "DSN signature chain failed",
148            Dkim2DsnFailure::ReturnedNotSigned => "Returned message is not DKIM2 signed",
149            Dkim2DsnFailure::ReturnedChainFailed => "Returned message signature chain failed",
150            Dkim2DsnFailure::NotAligned => {
151                "DSN signer is not aligned with the returned message recipient"
152            }
153            Dkim2DsnFailure::TempError => {
154                "DKIM2 chain could not be verified due to a temporary DNS error"
155            }
156        })
157    }
158}
159
160fn top_signature<'x>(
161    message: &'x AuthenticatedMessage<'x>,
162) -> Option<(&'x String, &'x str, &'x [String])> {
163    message
164        .dkim2_signatures
165        .iter()
166        .map(|h| &h.header)
167        .max_by_key(|s| s.i)
168        .map(envelope_of)
169}
170
171fn envelope_of(signature: &Signature) -> (&String, &str, &[String]) {
172    match &signature.chain {
173        ChainBinding::Envelope { mail_from, rcpt_to } => {
174            (&signature.d, mail_from.as_str(), rcpt_to.as_slice())
175        }
176        ChainBinding::NextDomain(_) => (&signature.d, "", &[][..]),
177    }
178}
179
180fn domain_of(address: &str) -> &str {
181    let address = address.trim_start_matches('<').trim_end_matches('>');
182    address.rsplit_once('@').map(|(_, d)| d).unwrap_or(address)
183}
184
185fn is_dkim2_signed(message: &AuthenticatedMessage<'_>) -> bool {
186    !message.dkim2_signatures.is_empty() || message.has_dkim2_errors
187}
188
189impl AuthenticatedMessage<'_> {
190    /// Returns the address a DSN about this message must be sent to.
191    ///
192    /// This is the `mf=` (`MAIL FROM`) of the signature with the highest
193    /// `i=`, including angle brackets. Returns `None` if the message has no
194    /// DKIM2 signature, if that signature uses `nd=`, or if its `MAIL FROM`
195    /// is the null reverse-path `<>`. Signatures that failed to parse are
196    /// not considered, so when [`has_dkim2_errors`](Self::has_dkim2_errors)
197    /// is `true` the address may come from an older hop.
198    pub fn dkim2_return_path(&self) -> Option<&str> {
199        dsn_return_path(self.dkim2_signatures.iter().map(|header| &header.header))
200    }
201
202    /// Returns the domains that may sign a DSN about this message (§16.2).
203    pub fn dkim2_dsn_domains(&self) -> impl Iterator<Item = &str> {
204        top_signature(self)
205            .map_or(&[][..], |(_, _, rcpt_to)| rcpt_to)
206            .iter()
207            .map(|rcpt| domain_of(rcpt))
208            .filter(|domain| !domain.starts_with('['))
209            .flat_map(|domain| {
210                std::iter::successors(Some(domain), |domain| {
211                    domain.split_once('.').map(|(_, parent)| parent)
212                })
213                .filter(|domain| domain.contains('.'))
214            })
215    }
216
217    /// Returns `true` if `domain` may sign a DSN about this message (§16.2).
218    pub fn dkim2_dsn_aligned(&self, domain: &str) -> bool {
219        top_signature(self).is_none_or(|(_, _, rcpt_to)| {
220            rcpt_to.is_empty()
221                || rcpt_to
222                    .iter()
223                    .any(|rcpt| relaxed_domain_match(domain_of(rcpt), domain))
224        })
225    }
226
227    /// Returns the header block as it was before the last `hops` DKIM2
228    /// signers handled it, for the returned part of a DSN (§16.3).
229    pub fn dkim2_arrival_headers(&self, hops: usize) -> Vec<u8> {
230        let signatures = &self.dkim2_signatures;
231        let kept = signatures
232            .get(..signatures.len().saturating_sub(hops))
233            .unwrap_or_default();
234        let arrival_m = kept.last().map_or(0, |signature| signature.header.m);
235        let (older, newer) = self.dkim2_instances.split_at(
236            self.dkim2_instances
237                .partition_point(|instance| instance.header.m <= arrival_m),
238        );
239
240        let mut out = Vec::with_capacity(self.raw_headers().len());
241        for (name, value) in kept
242            .iter()
243            .map(|header| (header.name, header.value))
244            .chain(older.iter().map(|header| (header.name, header.value)))
245        {
246            out.extend_from_slice(name);
247            out.push(b':');
248            out.extend_from_slice(value);
249        }
250
251        let mut undone: Option<Vec<u8>> = None;
252        for recipe in newer
253            .iter()
254            .rev()
255            .filter_map(|instance| instance.header.recipe.as_ref())
256        {
257            let mut previous = Vec::with_capacity(self.raw_headers().len());
258            match &undone {
259                Some(block) => recipe.apply_headers(
260                    &HeaderIterator::new(block).collect::<Vec<_>>(),
261                    &mut previous,
262                ),
263                None => recipe.apply_headers(self.headers(), &mut previous),
264            }
265            undone = Some(previous);
266        }
267        match undone {
268            Some(block) => out.extend_from_slice(&block),
269            None => Recipe::default().apply_headers(self.headers(), &mut out),
270        }
271        out
272    }
273}
274
275fn dsn_return_path<'x>(signatures: impl Iterator<Item = &'x Signature>) -> Option<&'x str> {
276    signatures
277        .max_by_key(|s| s.i)
278        .and_then(|top| match &top.chain {
279            ChainBinding::Envelope { mail_from, .. }
280                if !mail_from.is_empty() && mail_from != "<>" =>
281            {
282                Some(mail_from.as_str())
283            }
284            _ => None,
285        })
286}
287
288impl MessageAuthenticator {
289    /// Authenticates an inbound DKIM2-signed delivery status notification
290    /// (§16.4).
291    ///
292    /// `params` wraps the [`Dkim2Dsn`], optionally with a DNS cache (see
293    /// [`Parameters`]). `envelope` is the SMTP envelope the DSN was received
294    /// with, usually with a null `MAIL FROM` (`<>`).
295    ///
296    /// Both messages must be DKIM2 signed. A DSN is expected to carry a
297    /// single `DKIM2-Signature` and `Message-Instance` (§16.2), but a longer
298    /// chain is accepted when it verifies. The DSN is verified against
299    /// `envelope` as with
300    /// [`verify_dkim2`](MessageAuthenticator::verify_dkim2). The returned
301    /// message is verified against the envelope recorded in its most recent
302    /// signature; its body hashes are checked only when
303    /// [`Dkim2Dsn::returned_full`] is true. Finally the DSN must be aligned
304    /// with the returned message:
305    ///
306    /// 1. An `rt=` recipient domain of the returned message's most recent
307    ///    signature relaxed-matches the signing domain of the DSN's `i=1`
308    ///    signature.
309    /// 2. The `d=` of that signature relaxed-matches the domain of an
310    ///    `envelope` `RCPT TO` address, which shows that the receiving system
311    ///    made it.
312    ///
313    /// Each chain costs one DNS TXT lookup per `s=` entry of each signature,
314    /// for the public key at `<selector>._domainkey.<d>`.
315    ///
316    /// On success both fields of [`Dkim2DsnOutput`] are
317    /// [`Dkim2Result::Pass`].
318    ///
319    /// # Errors
320    ///
321    /// Returns [`Dkim2DsnFailure::DsnNotSigned`] or
322    /// [`Dkim2DsnFailure::ReturnedNotSigned`] if a message has no
323    /// `DKIM2-Signature` header field, [`Dkim2DsnFailure::DsnChainFailed`] or
324    /// [`Dkim2DsnFailure::ReturnedChainFailed`] if a chain does not pass
325    /// (including temporary DNS failures), and
326    /// [`Dkim2DsnFailure::NotAligned`] if the alignment checks fail.
327    ///
328    /// # Example
329    ///
330    /// ```rust,no_run
331    /// use mail_auth::{
332    ///     MessageAuthenticator, Parameters,
333    ///     dkim2::{Dkim2Dsn, Envelope},
334    /// };
335    ///
336    /// # async fn run(raw_dsn: &[u8]) {
337    /// let authenticator = MessageAuthenticator::new_cloudflare_tls().unwrap();
338    /// let dsn = Dkim2Dsn::parse(raw_dsn).unwrap();
339    ///
340    /// let envelope = Envelope::new("<>", ["sender@example.com"]);
341    /// match authenticator
342    ///     .verify_dkim2_dsn(Parameters::new(&dsn), envelope)
343    ///     .await
344    /// {
345    ///     Ok(_) => println!("authentic DSN"),
346    ///     Err(failure) => println!("DSN rejected: {failure}"),
347    /// }
348    /// # }
349    /// ```
350    pub async fn verify_dkim2_dsn<'x, R, S, C, A, RT>(
351        &self,
352        params: impl Into<Parameters<'x, &'x Dkim2Dsn<'x, R, S>, C>>,
353        envelope: Envelope<A, RT>,
354    ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
355    where
356        R: AsRef<AuthenticatedMessage<'x>> + 'x,
357        S: AsRef<AuthenticatedMessage<'x>> + 'x,
358        C: DnsCache + 'x,
359        A: AsRef<str> + Clone,
360        RT: IntoIterator<Item: AsRef<str>> + Clone,
361    {
362        let params = params.into();
363        self.verify_dkim2_dsn_(params.input, envelope, params.txt_cache(), now())
364            .await
365    }
366
367    pub(crate) async fn verify_dkim2_dsn_<'x, R, S, TXT, A, RT>(
368        &self,
369        dsn: &'x Dkim2Dsn<'x, R, S>,
370        envelope: Envelope<A, RT>,
371        cache_txt: Option<&TXT>,
372        now: u64,
373    ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure>
374    where
375        R: AsRef<AuthenticatedMessage<'x>> + 'x,
376        S: AsRef<AuthenticatedMessage<'x>> + 'x,
377        TXT: ResolverCache<Box<str>, TxtRecord>,
378        A: AsRef<str> + Clone,
379        RT: IntoIterator<Item: AsRef<str>> + Clone,
380    {
381        let dsn_message = dsn.dsn.as_ref();
382        if !is_dkim2_signed(dsn_message) {
383            return Err(Dkim2DsnFailure::DsnNotSigned);
384        } else if !is_dkim2_signed(dsn.returned.as_ref()) {
385            return Err(Dkim2DsnFailure::ReturnedNotSigned);
386        }
387
388        let dsn_output = self
389            .verify_dkim2_(
390                dsn_message,
391                envelope.clone(),
392                cache_txt,
393                now,
394                true,
395                Verification::Full,
396            )
397            .await;
398        let dsn_result = dsn_output.result;
399        match dsn_result {
400            Dkim2Result::Pass => {}
401            Dkim2Result::TempError(_) => return Err(Dkim2DsnFailure::TempError),
402            _ => return Err(Dkim2DsnFailure::DsnChainFailed),
403        }
404
405        let dsn_signing_domain = dsn_message
406            .dkim2_signatures
407            .iter()
408            .map(|h| &h.header)
409            .find(|signature| signature.i == 1)
410            .map(|signature| &signature.d);
411        let returned_top = top_signature(dsn.returned.as_ref());
412        let (returned_mail_from, returned_rcpt_to) = returned_top
413            .as_ref()
414            .map(|(_, mail_from, rcpt_to)| (*mail_from, *rcpt_to))
415            .unwrap_or_default();
416        let returned_result = self
417            .verify_dkim2_(
418                dsn.returned.as_ref(),
419                Envelope::new(returned_mail_from, returned_rcpt_to),
420                cache_txt,
421                now,
422                dsn.returned_full,
423                Verification::Full,
424            )
425            .await
426            .result;
427        match returned_result {
428            Dkim2Result::Pass => {}
429            Dkim2Result::TempError(_) => return Err(Dkim2DsnFailure::TempError),
430            _ => return Err(Dkim2DsnFailure::ReturnedChainFailed),
431        }
432
433        let aligned = match (&dsn_signing_domain, &returned_top) {
434            (Some(dsn_domain), Some((returned_domain, _, rcpt_to))) => {
435                let recipient_aligned = rcpt_to
436                    .iter()
437                    .any(|rcpt| relaxed_domain_match(domain_of(rcpt), dsn_domain));
438
439                let Envelope {
440                    rcpt_to: envelope_rcpt_to,
441                    ..
442                } = envelope;
443                let returned_is_ours = envelope_rcpt_to
444                    .into_iter()
445                    .any(|rcpt| relaxed_domain_match(domain_of(rcpt.as_ref()), returned_domain));
446
447                recipient_aligned && returned_is_ours
448            }
449            _ => false,
450        };
451
452        if aligned {
453            Ok(Dkim2DsnOutput {
454                dsn: dsn_result,
455                returned: returned_result,
456            })
457        } else {
458            Err(Dkim2DsnFailure::NotAligned)
459        }
460    }
461}
462
463impl<'x, R, S> From<&'x Dkim2Dsn<'x, R, S>> for Parameters<'x, &'x Dkim2Dsn<'x, R, S>, NoCache>
464where
465    R: AsRef<AuthenticatedMessage<'x>>,
466    S: AsRef<AuthenticatedMessage<'x>>,
467{
468    fn from(params: &'x Dkim2Dsn<'x, R, S>) -> Self {
469        Parameters::new(params)
470    }
471}
472
473#[cfg(test)]
474mod test {
475    use super::{Dkim2Dsn, Dkim2DsnFailure, Dkim2DsnOutput, Recipe, Signature, dsn_return_path};
476    use crate::{
477        AuthenticatedMessage, DnsError, Error, MessageAuthenticator,
478        crypto::Ed25519Key,
479        dkim::DomainKey,
480        dkim2::{ChainBinding, Dkim2Signer, Envelope, Hop},
481        dns::{TxtRecord, cache::test::DummyCaches},
482        parse::TxtRecordParser,
483    };
484    use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
485    use std::{
486        path::PathBuf,
487        time::{Duration, Instant},
488    };
489
490    const NOW: u64 = 1740002100;
491    const T: u64 = 1740000000;
492
493    fn resource(parts: &[&str]) -> PathBuf {
494        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
495        path.push("resources/dkim2");
496        for part in parts {
497            path.push(part);
498        }
499        path
500    }
501
502    fn load_key(domain: &str, selector: &str) -> Ed25519Key {
503        let pem = std::fs::read(resource(&[
504            "keys",
505            &format!("{selector}._domainkey.{domain}.pem"),
506        ]))
507        .unwrap();
508        let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
509            panic!("expected PKCS8 key");
510        };
511        Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
512    }
513
514    fn load_caches() -> DummyCaches {
515        let caches = DummyCaches::new();
516        let dns = std::fs::read(resource(&["dns.json"])).unwrap();
517        let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
518        let valid_until = Instant::now() + Duration::new(3600, 0);
519        for (domain, selectors) in dns.as_object().unwrap() {
520            for (selector, records) in selectors.as_object().unwrap() {
521                caches.txt_add(
522                    format!("{selector}.{domain}."),
523                    DomainKey::parse(records[0][1].as_str().unwrap().as_bytes()).unwrap(),
524                    valid_until,
525                );
526            }
527        }
528        caches
529    }
530
531    fn sign_full<A, R, I>(
532        key: Ed25519Key,
533        domain: &str,
534        selector: &str,
535        message: &[u8],
536        hop: Hop<A, R, I>,
537    ) -> Vec<u8>
538    where
539        A: AsRef<str>,
540        R: IntoIterator<Item: AsRef<str>>,
541        I: Into<String>,
542    {
543        let signer = Dkim2Signer::from_key(key).domain(domain).selector(selector);
544        let signed = signer.sign_at(message, hop, None, T).unwrap();
545        let mut out = signed.to_header().into_bytes();
546        out.extend_from_slice(message);
547        out
548    }
549
550    const RETURNED_PLAIN: &str = concat!(
551        "From: sender@test1.dkim2.com\r\n",
552        "To: user@test2.dkim2.com\r\n",
553        "Subject: Hello\r\n",
554        "Date: Sat, 01 Mar 2026 12:00:00 +0000\r\n",
555        "Message-ID: <m@test1.dkim2.com>\r\n",
556        "\r\n",
557        "This is the original body.\r\n",
558    );
559
560    fn signed_returned() -> Vec<u8> {
561        sign_full(
562            load_key("test1.dkim2.com", "ed25519"),
563            "test1.dkim2.com",
564            "ed25519",
565            RETURNED_PLAIN.as_bytes(),
566            Hop::real("sender@test1.dkim2.com", ["user@test2.dkim2.com"]),
567        )
568    }
569
570    fn headers_only(message: &[u8]) -> Vec<u8> {
571        let end = message.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
572        message[..end].to_vec()
573    }
574
575    fn make_dsn(returned: &[u8], returned_ct: &str, dsn_signed: bool) -> Vec<u8> {
576        let mut body = Vec::new();
577        body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: text/plain\r\n\r\n");
578        body.extend_from_slice(b"Delivery to user@test2.dkim2.com failed.\r\n");
579        body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/delivery-status\r\n\r\n");
580        body.extend_from_slice(b"Reporting-MTA: dns; test2.dkim2.com\r\n\r\n");
581        body.extend_from_slice(b"Final-Recipient: rfc822; user@test2.dkim2.com\r\n");
582        body.extend_from_slice(b"Action: failed\r\nStatus: 5.1.1\r\n");
583        body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: ");
584        body.extend_from_slice(returned_ct.as_bytes());
585        body.extend_from_slice(b"\r\n\r\n");
586        body.extend_from_slice(returned);
587        body.extend_from_slice(b"\r\n--BOUNDARY--\r\n");
588
589        let mut dsn_plain = Vec::new();
590        dsn_plain.extend_from_slice(b"From: postmaster@test2.dkim2.com\r\n");
591        dsn_plain.extend_from_slice(b"To: sender@test1.dkim2.com\r\n");
592        dsn_plain.extend_from_slice(b"Subject: Delivery Status Notification (Failure)\r\n");
593        dsn_plain.extend_from_slice(b"Date: Sat, 01 Mar 2026 12:05:00 +0000\r\n");
594        dsn_plain.extend_from_slice(
595            b"Content-Type: multipart/report; report-type=delivery-status; boundary=\"BOUNDARY\"\r\n",
596        );
597        dsn_plain.extend_from_slice(b"\r\n");
598        dsn_plain.extend_from_slice(&body);
599
600        if dsn_signed {
601            sign_full(
602                load_key("test2.dkim2.com", "ed25519"),
603                "test2.dkim2.com",
604                "ed25519",
605                &dsn_plain,
606                Hop::real("<>", ["sender@test1.dkim2.com"]),
607            )
608        } else {
609            dsn_plain
610        }
611    }
612
613    async fn verify(dsn_bytes: &[u8]) -> Result<Dkim2DsnOutput, Dkim2DsnFailure> {
614        verify_with(dsn_bytes, load_caches()).await
615    }
616
617    async fn verify_with(
618        dsn_bytes: &[u8],
619        caches: DummyCaches,
620    ) -> Result<Dkim2DsnOutput, Dkim2DsnFailure> {
621        let resolver = MessageAuthenticator::new_system_conf().unwrap();
622        let dsn = Dkim2Dsn::parse(dsn_bytes).expect("parse DSN");
623        let params = caches.parameters(&dsn);
624        let envelope = Envelope::new("<>", ["sender@test1.dkim2.com"]);
625        resolver
626            .verify_dkim2_dsn_(&dsn, envelope, params.txt_cache(), NOW)
627            .await
628    }
629
630    #[test]
631    fn dsn_return_path_null() {
632        let mut signature = Signature {
633            i: 1,
634            chain: ChainBinding::Envelope {
635                mail_from: "<>".to_string(),
636                rcpt_to: vec!["recipient@example.com".to_string()],
637            },
638            ..Default::default()
639        };
640        assert_eq!(dsn_return_path(std::iter::once(&signature)), None);
641
642        signature.chain = ChainBinding::Envelope {
643            mail_from: "sender@test1.dkim2.com".to_string(),
644            rcpt_to: vec!["recipient@example.com".to_string()],
645        };
646        assert_eq!(
647            dsn_return_path(std::iter::once(&signature)),
648            Some("sender@test1.dkim2.com")
649        );
650    }
651
652    fn forwarded(modify_subject: bool) -> Vec<u8> {
653        let returned = signed_returned();
654        let text = String::from_utf8(returned.clone()).unwrap();
655        let modified = if modify_subject {
656            text.replacen("Subject: Hello", "Subject: [fwd] Hello", 1)
657        } else {
658            text
659        };
660        let signed = Dkim2Signer::from_key(load_key("test2.dkim2.com", "ed25519"))
661            .domain("test2.dkim2.com")
662            .selector("ed25519")
663            .out_of_band_arrangement()
664            .sign_revised_at(
665                returned.as_slice(),
666                modified.as_bytes(),
667                Hop::real("user@test2.dkim2.com", ["final@example.com"]),
668                None,
669                T,
670            )
671            .unwrap();
672        let mut out = signed.to_header().into_bytes();
673        out.extend_from_slice(modified.as_bytes());
674        out
675    }
676
677    // Candidate DSN signing domains and the §16.2 alignment check follow the top rt=.
678    #[test]
679    fn dsn_signing_domains() {
680        let returned = signed_returned();
681        let message = AuthenticatedMessage::parse(&returned).unwrap();
682        assert_eq!(
683            message.dkim2_dsn_domains().collect::<Vec<_>>(),
684            ["test2.dkim2.com", "dkim2.com"]
685        );
686        assert!(message.dkim2_dsn_aligned("test2.dkim2.com"));
687        assert!(message.dkim2_dsn_aligned("DKIM2.com"));
688        assert!(!message.dkim2_dsn_aligned("sub.test2.dkim2.com"));
689        assert!(!message.dkim2_dsn_aligned("test1.dkim2.com"));
690
691        let unsigned = AuthenticatedMessage::parse(RETURNED_PLAIN.as_bytes()).unwrap();
692        assert_eq!(unsigned.dkim2_dsn_domains().count(), 0);
693        assert!(unsigned.dkim2_dsn_aligned("anything.example"));
694    }
695
696    // Dropping our hop restores the previous hop's DKIM2 fields and its signed headers.
697    #[test]
698    fn dsn_arrival_headers() {
699        let previous = signed_returned();
700        let previous = AuthenticatedMessage::parse(&previous).unwrap();
701        let mut expected = Vec::new();
702        Recipe::default().apply_headers(previous.headers(), &mut expected);
703
704        for modify_subject in [false, true] {
705            let forwarded = forwarded(modify_subject);
706            let message = AuthenticatedMessage::parse(&forwarded).unwrap();
707            assert_eq!(message.dkim2_signatures().len(), 2);
708
709            let arrival = message.dkim2_arrival_headers(1);
710            assert!(arrival.ends_with(&expected), "{modify_subject}");
711            let arrival = AuthenticatedMessage::parse(&arrival).unwrap();
712            assert_eq!(
713                arrival
714                    .dkim2_signatures()
715                    .iter()
716                    .map(|signature| signature.header.i)
717                    .collect::<Vec<_>>(),
718                [1]
719            );
720            assert_eq!(
721                arrival
722                    .dkim2_instances()
723                    .iter()
724                    .map(|instance| instance.header.m)
725                    .collect::<Vec<_>>(),
726                [1]
727            );
728            assert!(arrival.dkim2_dsn_aligned("test2.dkim2.com"));
729
730            let unchanged = AuthenticatedMessage::parse(&forwarded).unwrap();
731            let all = unchanged.dkim2_arrival_headers(0);
732            let all = AuthenticatedMessage::parse(&all).unwrap();
733            assert_eq!(all.dkim2_signatures().len(), 2);
734            assert_eq!(
735                all.dkim2_instances().len(),
736                if modify_subject { 2 } else { 1 }
737            );
738
739            let none = unchanged.dkim2_arrival_headers(2);
740            let none = AuthenticatedMessage::parse(&none).unwrap();
741            assert!(none.dkim2_signatures().is_empty());
742            assert!(none.dkim2_instances().is_empty());
743            assert!(
744                none.headers()
745                    .iter()
746                    .any(|(name, _)| name.eq_ignore_ascii_case(b"From"))
747            );
748        }
749    }
750
751    // Two consecutive own hops, each editing a header, are both undone and the result verifies at the first hop.
752    #[tokio::test]
753    async fn verify_dsn_with_two_own_hops() {
754        let returned = signed_returned();
755        let hop =
756            |message: &[u8], domain: &str, from: &str, to: &str, find: &str, replace: &str| {
757                let text = String::from_utf8(message.to_vec()).unwrap();
758                let modified = text.replacen(find, replace, 1);
759                let signed = Dkim2Signer::from_key(load_key(domain, "ed25519"))
760                    .domain(domain)
761                    .selector("ed25519")
762                    .out_of_band_arrangement()
763                    .sign_revised_at(message, modified.as_bytes(), Hop::real(from, [to]), None, T)
764                    .unwrap();
765                let mut out = signed.to_header().into_bytes();
766                out.extend_from_slice(modified.as_bytes());
767                out
768            };
769        let hop2 = hop(
770            &returned,
771            "test2.dkim2.com",
772            "user@test2.dkim2.com",
773            "relay@test3.dkim2.com",
774            "Subject: Hello",
775            "Subject: [list] Hello",
776        );
777        let hop3 = hop(
778            &hop2,
779            "test3.dkim2.com",
780            "relay@test3.dkim2.com",
781            "final@example.com",
782            "Subject: [list] Hello",
783            "Subject: [list] [fwd] Hello",
784        );
785        let message = AuthenticatedMessage::parse(&hop3).unwrap();
786        assert_eq!(message.dkim2_signatures().len(), 3);
787        assert_eq!(message.dkim2_instances().len(), 3);
788
789        let arrival = message.dkim2_arrival_headers(2);
790        assert!(!String::from_utf8_lossy(&arrival).contains("[list]"));
791        let dsn = make_dsn(&arrival, "text/rfc822-headers", true);
792        let output = verify(&dsn).await;
793        assert!(output.is_ok(), "{output:?}");
794    }
795
796    // A DSN carrying the rebuilt arrival headers passes §16.4 at the previous hop; the raw ones do not.
797    #[tokio::test]
798    async fn verify_dsn_with_arrival_headers() {
799        for modify_subject in [false, true] {
800            let forwarded = forwarded(modify_subject);
801            let message = AuthenticatedMessage::parse(&forwarded).unwrap();
802
803            let dsn = make_dsn(
804                &message.dkim2_arrival_headers(1),
805                "text/rfc822-headers",
806                true,
807            );
808            let output = verify(&dsn).await;
809            assert!(output.is_ok(), "{modify_subject}: {output:?}");
810
811            let dsn = make_dsn(&headers_only(&forwarded), "text/rfc822-headers", true);
812            assert_eq!(verify(&dsn).await, Err(Dkim2DsnFailure::NotAligned));
813        }
814    }
815
816    #[tokio::test]
817    async fn verify_dsn_round_trip() {
818        let dsn_signed = make_dsn(&signed_returned(), "message/rfc822", true);
819        assert!(Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
820
821        let output = verify(&dsn_signed).await;
822        assert!(output.is_ok(), "{output:?}");
823    }
824
825    // A DSN relayed through a second signer verifies; §16.2 single-signature is a sender rule.
826    #[tokio::test]
827    async fn verify_dsn_with_two_signatures() {
828        let plain = make_dsn(&signed_returned(), "message/rfc822", false);
829        let hop1 = sign_full(
830            load_key("test2.dkim2.com", "ed25519"),
831            "test2.dkim2.com",
832            "ed25519",
833            &plain,
834            Hop::real("<>", ["relay@test3.dkim2.com"]),
835        );
836        let signed = Dkim2Signer::from_key(load_key("test3.dkim2.com", "ed25519"))
837            .domain("test3.dkim2.com")
838            .selector("ed25519")
839            .out_of_band_arrangement()
840            .sign_at(
841                hop1.as_slice(),
842                Hop::real("<>", ["sender@test1.dkim2.com"]),
843                None,
844                T,
845            )
846            .unwrap();
847        let mut hop2 = signed.to_header().into_bytes();
848        hop2.extend_from_slice(&hop1);
849
850        let output = verify(&hop2).await;
851        assert!(output.is_ok(), "{output:?}");
852    }
853
854    #[tokio::test]
855    async fn verify_dsn_returned_headers_only() {
856        let dsn_signed = make_dsn(
857            &headers_only(&signed_returned()),
858            "text/rfc822-headers",
859            true,
860        );
861        assert!(!Dkim2Dsn::parse(&dsn_signed).unwrap().returned_full);
862
863        let output = verify(&dsn_signed).await;
864        assert!(output.is_ok(), "{output:?}");
865    }
866
867    #[tokio::test]
868    async fn verify_dsn_returned_not_signed() {
869        let dsn_signed = make_dsn(RETURNED_PLAIN.as_bytes(), "message/rfc822", true);
870
871        let output = verify(&dsn_signed).await;
872        assert_eq!(output, Err(Dkim2DsnFailure::ReturnedNotSigned));
873    }
874
875    // A temporary DNS failure on either chain is reported as a temporary error, not a forgery.
876    #[tokio::test]
877    async fn verify_dsn_temporary_dns_failure() {
878        let dsn_signed = make_dsn(&signed_returned(), "message/rfc822", true);
879        for domain in ["test2.dkim2.com", "test1.dkim2.com"] {
880            let caches = load_caches();
881            caches.txt_add(
882                format!("ed25519._domainkey.{domain}."),
883                TxtRecord::Error(Error::Dns(DnsError::Resolver("timeout".to_string()))),
884                Instant::now() + Duration::new(3600, 0),
885            );
886            assert_eq!(
887                verify_with(&dsn_signed, caches).await,
888                Err(Dkim2DsnFailure::TempError),
889                "{domain}"
890            );
891        }
892    }
893
894    #[tokio::test]
895    async fn verify_dsn_not_signed() {
896        let dsn_unsigned = make_dsn(&signed_returned(), "message/rfc822", false);
897
898        let output = verify(&dsn_unsigned).await;
899        assert_eq!(output, Err(Dkim2DsnFailure::DsnNotSigned));
900    }
901}