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//! Authentication of inbound DKIM2-signed delivery status notifications
8//! (§16.3) through [`MessageAuthenticator::verify_dkim2_dsn`].
9
10use 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/// An inbound delivery status notification (DSN) and the message it returns.
23///
24/// Input of [`MessageAuthenticator::verify_dkim2_dsn`]. Build it with
25/// [`Dkim2Dsn::parse`] from a raw `multipart/report` DSN, or with
26/// [`Dkim2Dsn::new`] from messages parsed by the caller.
27#[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    /// The DSN message itself, including its own DKIM2 header fields.
34    pub dsn: R,
35    /// The returned message embedded in the DSN.
36    pub returned: S,
37    /// Whether `returned` includes the body (`message/rfc822`) or only the
38    /// header fields (`text/rfc822-headers`). Without the body, body hashes
39    /// are not checked.
40    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    /// Creates a `Dkim2Dsn` from the parsed DSN, the parsed returned message
50    /// and whether the returned message includes its body.
51    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    /// Parses a `multipart/report` DSN and locates the embedded returned
63    /// message, a `message/rfc822` or `text/rfc822-headers` part. If several
64    /// parts qualify, the last one is used.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Dkim2DsnFailure::DsnUnparseable`] if `raw_message` is not a
69    /// parseable multipart message, and
70    /// [`Dkim2DsnFailure::ReturnedUnparseable`] if it has no returned message
71    /// part or that part cannot be parsed.
72    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/// Result of a successful
106/// [`MessageAuthenticator::verify_dkim2_dsn`].
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Dkim2DsnOutput {
109    /// Verification result of the DSN's own DKIM2 chain.
110    pub dsn: Dkim2Result,
111    /// Verification result of the returned message's DKIM2 chain.
112    pub returned: Dkim2Result,
113}
114
115/// Reason an inbound DSN failed parsing or authentication.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Dkim2DsnFailure {
118    /// The DSN is not a parseable multipart message.
119    DsnUnparseable,
120    /// The DSN has no returned message part, or it cannot be parsed.
121    ReturnedUnparseable,
122    /// The DSN has no `DKIM2-Signature` header field, not even a malformed
123    /// one.
124    DsnNotSigned,
125    /// The DSN's DKIM2 chain did not pass verification.
126    DsnChainFailed,
127    /// The returned message has no `DKIM2-Signature` header field.
128    ReturnedNotSigned,
129    /// The returned message's DKIM2 chain did not pass verification.
130    ReturnedChainFailed,
131    /// The DSN signer is not a recipient of the returned message, or the
132    /// returned message was not last signed by the receiving system.
133    NotAligned,
134    /// A DKIM2 chain of the DSN or of the returned message could not be
135    /// verified because of a temporary DNS error (§13.2); the DSN should be
136    /// rejected temporarily so that the sender retries.
137    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    /// Returns the address a DSN about this message must be sent to.
190    ///
191    /// This is the `mf=` (`MAIL FROM`) of the signature with the highest
192    /// `i=`, including angle brackets. Returns `None` if the message has no
193    /// DKIM2 signature, if that signature uses `nd=`, or if its `MAIL FROM`
194    /// is the null reverse-path `<>`. Signatures that failed to parse are
195    /// not considered, so when [`has_dkim2_errors`](Self::has_dkim2_errors)
196    /// is `true` the address may come from an older hop.
197    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    /// Authenticates an inbound DKIM2-signed delivery status notification
217    /// (§16.3).
218    ///
219    /// `params` wraps the [`Dkim2Dsn`], optionally with a DNS cache (see
220    /// [`Parameters`]). `envelope` is the SMTP envelope the DSN was received
221    /// with, usually with a null `MAIL FROM` (`<>`).
222    ///
223    /// Both messages must be DKIM2 signed. A DSN is expected to carry a
224    /// single `DKIM2-Signature` and `Message-Instance` (§16.2), but a longer
225    /// chain is accepted when it verifies. The DSN is verified against
226    /// `envelope` as with
227    /// [`verify_dkim2`](MessageAuthenticator::verify_dkim2). The returned
228    /// message is verified against the envelope recorded in its most recent
229    /// signature; its body hashes are checked only when
230    /// [`Dkim2Dsn::returned_full`] is true. Finally the DSN must be aligned
231    /// with the returned message:
232    ///
233    /// 1. An `rt=` recipient domain of the returned message's most recent
234    ///    signature relaxed-matches the signing domain of the DSN's `i=1`
235    ///    signature.
236    /// 2. The `d=` of that signature relaxed-matches the domain of an
237    ///    `envelope` `RCPT TO` address, which shows that the receiving system
238    ///    made it.
239    ///
240    /// Each chain costs one DNS TXT lookup per `s=` entry of each signature,
241    /// for the public key at `<selector>._domainkey.<d>`.
242    ///
243    /// On success both fields of [`Dkim2DsnOutput`] are
244    /// [`Dkim2Result::Pass`].
245    ///
246    /// # Errors
247    ///
248    /// Returns [`Dkim2DsnFailure::DsnNotSigned`] or
249    /// [`Dkim2DsnFailure::ReturnedNotSigned`] if a message has no
250    /// `DKIM2-Signature` header field, [`Dkim2DsnFailure::DsnChainFailed`] or
251    /// [`Dkim2DsnFailure::ReturnedChainFailed`] if a chain does not pass
252    /// (including temporary DNS failures), and
253    /// [`Dkim2DsnFailure::NotAligned`] if the alignment checks fail.
254    ///
255    /// # Example
256    ///
257    /// ```rust,no_run
258    /// use mail_auth::{
259    ///     MessageAuthenticator, Parameters,
260    ///     dkim2::{Dkim2Dsn, Envelope},
261    /// };
262    ///
263    /// # async fn run(raw_dsn: &[u8]) {
264    /// let authenticator = MessageAuthenticator::new_cloudflare_tls().unwrap();
265    /// let dsn = Dkim2Dsn::parse(raw_dsn).unwrap();
266    ///
267    /// let envelope = Envelope::new("<>", ["sender@example.com"]);
268    /// match authenticator
269    ///     .verify_dkim2_dsn(Parameters::new(&dsn), envelope)
270    ///     .await
271    /// {
272    ///     Ok(_) => println!("authentic DSN"),
273    ///     Err(failure) => println!("DSN rejected: {failure}"),
274    /// }
275    /// # }
276    /// ```
277    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    // A DSN relayed through a second signer verifies; §16.2 single-signature is a sender rule.
589    #[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    // A temporary DNS failure on either chain is reported as a temporary error, not a forgery.
639    #[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}