Skip to main content

mail_auth/dkim2/
verify.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::{
8    ChainBinding, ChainLink, Dkim2Error, Dkim2Output, Flag, MessageInstance, Signature,
9    sign::Envelope,
10};
11use crate::{
12    AuthenticatedMessage, Dkim2Result, DnsError, Error, MX, MessageAuthenticator, Parameters,
13    RecordSet, ResolverCache, Txt,
14    common::{
15        crypto::{Algorithm, CryptoError, HashAlgorithm},
16        headers::{Header, HeaderIterator, HeaderStream, Writer},
17        verify::DomainKey,
18    },
19    dkim::DkimError,
20    dkim2::{canonicalize::CanonicalizedHeaderWriter, sign::now},
21};
22use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23
24const MAX_AGE: u64 = 14 * 86400;
25const MAX_CHAIN_LENGTH: usize = 50;
26
27impl MessageAuthenticator {
28    /// Verifies the DKIM2 signature chain of an RFC5322 message.
29    pub async fn verify_dkim2<'x, TXT, MXX, IPV4, IPV6, PTR, A, R>(
30        &self,
31        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
32        envelope: Envelope<A, R>,
33    ) -> Dkim2Output<'x>
34    where
35        TXT: ResolverCache<Box<str>, Txt> + 'x,
36        MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
37        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
38        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
39        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
40        A: AsRef<str>,
41        R: IntoIterator<Item: AsRef<str>>,
42    {
43        let params = params.into();
44        self.verify_dkim2_(params.params, envelope, params.cache_txt, now(), true)
45            .await
46    }
47
48    pub(crate) async fn verify_dkim2_<'x, TXT, A, R>(
49        &self,
50        message: &'x AuthenticatedMessage<'x>,
51        envelope: Envelope<A, R>,
52        cache_txt: Option<&TXT>,
53        now: u64,
54        body_present: bool,
55    ) -> Dkim2Output<'x>
56    where
57        TXT: ResolverCache<Box<str>, Txt>,
58        A: AsRef<str>,
59        R: IntoIterator<Item: AsRef<str>>,
60    {
61        if message.has_dkim2_errors {
62            for header in &message.errors {
63                let name = header.name.trim_ascii();
64
65                if name.eq_ignore_ascii_case(b"dkim2-signature")
66                    || name.eq_ignore_ascii_case(b"message-instance")
67                {
68                    return Dkim2Result::from(header.header.clone()).into();
69                }
70            }
71        }
72
73        if message.dkim2_signatures.is_empty() {
74            return Dkim2Result::None.into();
75        } else if message.dkim2_signatures.len() > MAX_CHAIN_LENGTH
76            || message.dkim2_instances.len() > MAX_CHAIN_LENGTH
77        {
78            return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::ChainTooLong)).into();
79        }
80
81        let signatures = message.dkim2_signatures.as_slice();
82        let instances = message.dkim2_instances.as_slice();
83
84        for (index, header) in signatures.iter().enumerate() {
85            let signature = &header.header;
86            let expected = index as u32 + 1;
87            if signature.i != expected {
88                return Dkim2Result::None.into();
89            }
90            for (present, tag) in [(signature.m != 0, "m"), (!signature.d.is_empty(), "d")] {
91                if !present {
92                    return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagMissing {
93                        i: signature.i,
94                        tag,
95                    }))
96                    .into();
97                }
98            }
99            if let ChainBinding::Envelope { mail_from, rcpt_to } = &signature.chain {
100                if mail_from.is_empty() && rcpt_to.is_empty() {
101                    return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagMissing {
102                        i: signature.i,
103                        tag: "mf",
104                    }))
105                    .into();
106                }
107                if require_reverse_path()
108                    && !(is_reverse_path(mail_from) && rcpt_to.iter().all(|r| is_reverse_path(r)))
109                {
110                    return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureSyntax(
111                        signature.i,
112                    )))
113                    .into();
114                }
115            }
116            if now > signature.t && now - signature.t > MAX_AGE {
117                return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureExpired(
118                    signature.i,
119                )))
120                .into();
121            }
122        }
123
124        for (index, header) in instances.iter().enumerate() {
125            let instance = &header.header;
126            if instance.m != index as u32 + 1 {
127                return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceMissing(
128                    index as u32 + 1,
129                )))
130                .into();
131            }
132        }
133
134        let highest_sig_m = signatures.last().map(|h| h.header.m).unwrap_or(0);
135        let highest_mi_m = instances.last().map(|h| h.header.m).unwrap_or(0);
136        if highest_mi_m == 0 {
137            return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceMissing(1))).into();
138        }
139        if highest_mi_m != highest_sig_m {
140            return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceAboveSignature(
141                highest_mi_m,
142            )))
143            .into();
144        }
145
146        let top_signature = &signatures.last().unwrap().header;
147        match &top_signature.chain {
148            ChainBinding::Envelope { mail_from, rcpt_to } => {
149                if !address_matches(envelope.mail_from.as_ref(), mail_from) {
150                    return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::MailFromMismatch(
151                        top_signature.i,
152                    )))
153                    .into();
154                }
155                for rcpt in envelope.rcpt_to {
156                    if !rcpt_to.iter().any(|r| address_matches(rcpt.as_ref(), r)) {
157                        return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::RcptToMismatch(
158                            top_signature.i,
159                        )))
160                        .into();
161                    }
162                }
163                if mail_from != "<>" {
164                    let (_, domain) = local_and_domain(mail_from);
165                    if !relaxed_domain_match(domain, &top_signature.d) {
166                        return Dkim2Result::PermError(Error::Dkim2(
167                            Dkim2Error::MailFromDomainMismatch(top_signature.i),
168                        ))
169                        .into();
170                    }
171                }
172            }
173            ChainBinding::NextDomain(_) => {
174                return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
175                    i: top_signature.i,
176                    tag: "nd",
177                }))
178                .into();
179            }
180        }
181
182        for window in signatures.windows(2) {
183            let previous = &window[0].header;
184            let current = &window[1].header;
185            match &previous.chain {
186                ChainBinding::NextDomain(next_domain) => {
187                    if !next_domain.eq_ignore_ascii_case(&current.d) {
188                        return Dkim2Result::PermError(Error::Dkim2(
189                            Dkim2Error::NextDomainMismatch(current.i),
190                        ))
191                        .into();
192                    }
193                }
194                ChainBinding::Envelope { rcpt_to, .. } => {
195                    let current_domain = match &current.chain {
196                        ChainBinding::Envelope { mail_from, .. } => local_and_domain(mail_from).1,
197                        ChainBinding::NextDomain(_) => current.d.as_str(),
198                    };
199                    let custody_ok = rcpt_to.iter().any(|rcpt| {
200                        let (_, rcpt_domain) = local_and_domain(rcpt);
201                        relaxed_domain_match(current_domain, rcpt_domain)
202                    });
203                    if !custody_ok {
204                        let error = match &current.chain {
205                            ChainBinding::Envelope { .. } => {
206                                Dkim2Error::MailFromMismatch(current.i)
207                            }
208                            ChainBinding::NextDomain(_) => Dkim2Error::CustodyBreak(current.i),
209                        };
210                        return Dkim2Result::PermError(Error::Dkim2(error)).into();
211                    }
212                }
213            }
214        }
215
216        for sig_header in signatures {
217            let signature = &sig_header.header;
218            if signature.s.is_empty() {
219                return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::NoValidAlgorithm(signature.i)))
220                    .into();
221            }
222
223            let mut input = Vec::with_capacity(256);
224            for (name, value) in instances
225                .iter()
226                .filter(|h| h.header.m <= signature.m)
227                .map(|h| (h.name, h.value))
228                .chain(
229                    signatures
230                        .iter()
231                        .filter(|h| h.header.i < signature.i)
232                        .map(|h| (h.name, h.value)),
233                )
234            {
235                let mut w = CanonicalizedHeaderWriter::new(&mut input, name);
236                w.write(value);
237                w.finalize();
238            }
239            strip_and_canonicalize_signature(sig_header.value, &mut input);
240
241            for value in &signature.s {
242                let key = match self
243                    .txt_lookup::<DomainKey>(
244                        format!("{}._domainkey.{}.", value.selector, signature.d),
245                        cache_txt,
246                    )
247                    .await
248                {
249                    Ok(key) => key,
250                    Err(Error::Dns(DnsError::Resolver(_))) => {
251                        return Dkim2Result::TempError(Error::Dkim2(Dkim2Error::PublicKeyFetch(
252                            signature.i,
253                        )))
254                        .into();
255                    }
256                    Err(Error::Dkim(DkimError::RevokedPublicKey)) => {
257                        return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeyRevoked(
258                            signature.i,
259                        )))
260                        .into();
261                    }
262                    Err(_) => {
263                        return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeyMissing(
264                            signature.i,
265                        )))
266                        .into();
267                    }
268                };
269
270                if matches!(value.a, Algorithm::RsaSha256 | Algorithm::RsaSha1)
271                    && key.p.public_key_bits() < 1024
272                {
273                    return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeySyntax(
274                        signature.i,
275                    )))
276                    .into();
277                }
278
279                match key.p.verify_bytes(&input, &value.b, value.a) {
280                    Ok(()) => {}
281                    Err(Error::Crypto(CryptoError::IncompatibleAlgorithms)) => {
282                        return Dkim2Result::PermError(Error::Dkim2(
283                            Dkim2Error::PublicKeyAlgorithmMismatch(signature.i),
284                        ))
285                        .into();
286                    }
287                    Err(_) => {
288                        return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::IncorrectSignature(
289                            signature.i,
290                        )))
291                        .into();
292                    }
293                }
294            }
295        }
296
297        let algorithm = HashAlgorithm::Sha256;
298        let mut new_body = vec![];
299        let mut new_haders = vec![];
300        let mut last_body = message.raw_body();
301        let mut last_headers = message.headers.as_slice();
302
303        for header in instances.iter().rev() {
304            let instance = &header.header;
305            let Some(recorded) = instance.hashes.iter().find(|h| h.name == Some(algorithm)) else {
306                continue;
307            };
308
309            let header_hash = algorithm.headers_hash(last_headers.iter().copied());
310
311            if header_hash.as_ref() != recorded.header_hash {
312                return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(instance.m)))
313                    .into();
314            }
315            if !body_present {
316                break;
317            }
318            let body_hash = algorithm.body_hash(last_body);
319            if body_hash.as_ref() != recorded.body_hash {
320                return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(instance.m)))
321                    .into();
322            }
323            if instance.m > 1
324                && let Some(recipe) = &instance.recipe
325            {
326                match recipe.apply(last_headers, last_body) {
327                    Ok(previous) => {
328                        new_body = previous;
329                        let mut iter = HeaderIterator::new(&new_body);
330                        new_haders = iter.by_ref().collect();
331                        last_body = iter.body();
332                        last_headers = new_haders.as_slice();
333                    }
334                    Err(_) => {
335                        return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(
336                            instance.m,
337                        )))
338                        .into();
339                    }
340                }
341            }
342        }
343
344        if let Some(error) = flag_violation(signatures, instances, algorithm) {
345            return Dkim2Result::Fail(Error::Dkim2(error)).into();
346        }
347
348        Dkim2Output {
349            result: Dkim2Result::Pass,
350            chain: signatures
351                .iter()
352                .map(|sig_header| ChainLink {
353                    signature: &sig_header.header,
354                    instance: instances
355                        .iter()
356                        .find(|h| h.header.m == sig_header.header.m)
357                        .map(|h| &h.header),
358                    result: Dkim2Result::Pass,
359                    custody_ok: true,
360                })
361                .collect(),
362        }
363    }
364}
365
366fn flag_violation(
367    signatures: &[Header<'_, Signature>],
368    instances: &[Header<'_, MessageInstance>],
369    algorithm: HashAlgorithm,
370) -> Option<Dkim2Error> {
371    let mut protected_m: Option<u32> = None;
372    let mut protected_i: Option<u32> = None;
373    for header in signatures {
374        let signature = &header.header;
375        if signature.flags.contains(&Flag::DoNotModify) {
376            protected_m = Some(protected_m.map_or(signature.m, |m| m.min(signature.m)));
377        }
378        if signature.flags.contains(&Flag::DoNotExplode) {
379            protected_i = Some(protected_i.map_or(signature.i, |i| i.min(signature.i)));
380        }
381    }
382
383    if let Some(protected_m) = protected_m
384        && let Some(reference) = instances
385            .iter()
386            .find(|h| h.header.m == protected_m)
387            .and_then(|h| h.header.hashes.iter().find(|h| h.name == Some(algorithm)))
388            .map(|h| (h.header_hash.as_slice(), h.body_hash.as_slice()))
389    {
390        for header in instances {
391            let instance = &header.header;
392            if instance.m > protected_m
393                && let Some(hashes) = instance.hashes.iter().find(|h| h.name == Some(algorithm))
394                && (hashes.header_hash.as_slice(), hashes.body_hash.as_slice()) != reference
395            {
396                return Some(Dkim2Error::Modified);
397            }
398        }
399    }
400
401    if let Some(protected_i) = protected_i
402        && signatures
403            .iter()
404            .any(|h| h.header.i > protected_i && h.header.flags.contains(&Flag::Exploded))
405    {
406        return Some(Dkim2Error::Exploded);
407    }
408
409    None
410}
411
412fn local_and_domain(address: &str) -> (&str, &str) {
413    let address = address.strip_prefix('<').unwrap_or(address);
414    let address = address.strip_suffix('>').unwrap_or(address);
415    match address.rsplit_once('@') {
416        Some((local, domain)) => (local, domain),
417        None => (address, ""),
418    }
419}
420
421/// Exact reverse-path / forward-path comparison for the chain-of-custody check
422fn address_matches(envelope: &str, signed: &str) -> bool {
423    let (el, ed) = local_and_domain(envelope);
424    let (sl, sd) = local_and_domain(signed);
425    el == sl && ed.eq_ignore_ascii_case(sd)
426}
427
428/// Whether a signed mf=/rt= value is a well-formed RFC5321 reverse-path
429#[inline(always)]
430fn is_reverse_path(value: &str) -> bool {
431    value.starts_with('<') && value.ends_with('>')
432}
433
434/// Whether the verifier requires signed mf=/rt= values to carry angle brackets.
435#[inline(always)]
436fn require_reverse_path() -> bool {
437    #[cfg(test)]
438    {
439        test_reverse_path::required()
440    }
441    #[cfg(not(test))]
442    {
443        true
444    }
445}
446
447pub(crate) fn relaxed_domain_match(mail_from_domain: &str, signing_domain: &str) -> bool {
448    let mut current = mail_from_domain;
449    loop {
450        if current.eq_ignore_ascii_case(signing_domain) {
451            return true;
452        }
453        match current.split_once('.') {
454            Some((_, rest)) if !rest.is_empty() => current = rest,
455            _ => return false,
456        }
457    }
458}
459
460/// Blanks the base64 signature value(s) in the `s=`
461fn strip_and_canonicalize_signature(signature: &[u8], out: &mut Vec<u8>) {
462    out.extend(b"dkim2-signature:".as_slice());
463    let mut iter = signature.iter().peekable();
464    let mut last_ch = b' ';
465    while let Some(&ch) = iter.next() {
466        if !ch.is_ascii_whitespace() {
467            if matches!(ch, b's' | b'S') && matches!(last_ch, b' ' | b';') {
468                let mut found_eq = false;
469                while let Some(next_ch) = iter.peek() {
470                    match next_ch {
471                        b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' => {
472                            iter.next();
473                        }
474                        b'=' => {
475                            found_eq = true;
476                            iter.next();
477                            break;
478                        }
479                        _ => break,
480                    }
481                }
482
483                if found_eq {
484                    out.push(ch);
485                    out.push(b'=');
486                    'next_signature: loop {
487                        // Write up to second colon
488                        let mut found_colon = false;
489                        for &ch in iter.by_ref() {
490                            match ch {
491                                b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' => {}
492                                b':' => {
493                                    out.push(ch);
494                                    if !found_colon {
495                                        found_colon = true;
496                                    } else {
497                                        break;
498                                    }
499                                }
500                                b';' => {
501                                    out.push(ch);
502                                    break 'next_signature;
503                                }
504                                b',' => {
505                                    out.push(ch);
506                                    continue 'next_signature;
507                                }
508                                _ => {
509                                    out.push(ch);
510                                }
511                            }
512                        }
513
514                        // Skip until next comma or EOF
515                        for &ch in iter.by_ref() {
516                            match ch {
517                                b';' => {
518                                    out.push(ch);
519                                    break 'next_signature;
520                                }
521                                b',' => {
522                                    out.push(ch);
523                                    continue 'next_signature;
524                                }
525                                _ => {}
526                            }
527                        }
528
529                        break;
530                    }
531                    last_ch = b' ';
532                    continue;
533                }
534            }
535
536            out.push(ch);
537            last_ch = ch;
538        } else {
539            last_ch = b' ';
540        }
541    }
542
543    out.extend(b"\r\n");
544}
545
546#[cfg(test)]
547mod canonicalize_test {
548    #[test]
549    fn strip_and_canonicalize_signature() {
550        for (value, expected) in [
551            // Baseline: WSP deleted, name lowercased, s= signature blanked.
552            (
553                "i=1; m=1; t=5; d=ex.com; mf=YQ==; rt=Yg==; s=sel:alg:U0lH;",
554                "dkim2-signature:i=1;m=1;t=5;d=ex.com;mf=YQ==;rt=Yg==;s=sel:alg:;\r\n",
555            ),
556            // s= is the last tag, no trailing semicolon.
557            ("i=1; s=sel:alg:U0lH", "dkim2-signature:i=1;s=sel:alg:\r\n"),
558            // Multiple algorithm sets in s=.
559            ("s=a:b:U0lH,c:d:WkZa;", "dkim2-signature:s=a:b:,c:d:;\r\n"),
560            // f= after s=.
561            (
562                "s=sel:alg:U0lH; f=donotmodify;",
563                "dkim2-signature:s=sel:alg:;f=donotmodify;\r\n",
564            ),
565            // Folding: CRLF + WSP everywhere, including inside the signature.
566            (
567                "i=1;\r\n m=1;\r\n\ts=sel:alg:U0\r\n lH;",
568                "dkim2-signature:i=1;m=1;s=sel:alg:;\r\n",
569            ),
570            // Leading and trailing whitespace.
571            ("  i=1; s=a:b:CC;  ", "dkim2-signature:i=1;s=a:b:;\r\n"),
572            // s= as the first tag.
573            ("s=a:b:CC; i=1;", "dkim2-signature:s=a:b:;i=1;\r\n"),
574            // No whitespace at all.
575            (
576                "i=1;s=a:b:CC;f=exploded;",
577                "dkim2-signature:i=1;s=a:b:;f=exploded;\r\n",
578            ),
579            // A nonce (no colons) preceding s=.
580            ("n=foo; s=a:b:CC;", "dkim2-signature:n=foo;s=a:b:;\r\n"),
581            // Realistic ed25519 signature.
582            (
583                "d=sub.ex.com; s=ed25519:ed25519-sha256:F//Dt+leS4H;",
584                "dkim2-signature:d=sub.ex.com;s=ed25519:ed25519-sha256:;\r\n",
585            ),
586            // Empty value.
587            ("", "dkim2-signature:\r\n"),
588            // A value byte 's' that is not a tag (preceded by a non-boundary char).
589            ("d=as; s=a:b:CC;", "dkim2-signature:d=as;s=a:b:;\r\n"),
590            // Adversarial: uppercase S= tag (tag names are case-insensitive, §8).
591            ("S=sel:alg:CC;", "dkim2-signature:S=sel:alg:;\r\n"),
592            // Adversarial: malformed s= (no colons) followed by a colon-bearing nonce.
593            (
594                "s=badset; n=a:b:c;",
595                "dkim2-signature:s=badset;n=a:b:c;\r\n",
596            ),
597            // Adversarial: empty s= followed by a colon-bearing nonce.
598            ("s=; n=a:b:c;", "dkim2-signature:s=;n=a:b:c;\r\n"),
599            // Adversarial: single set with only one colon.
600            ("s=sel:alg; i=1;", "dkim2-signature:s=sel:alg;i=1;\r\n"),
601            // FWS inside a base64 value that ends in an "s=" (padding) before a real s=.
602            (
603                "i=1; mf=QQ s=; s=a:b:CC;",
604                "dkim2-signature:i=1;mf=QQs=;s=a:b:;\r\n",
605            ),
606            // FWS base64 ending in "s=" followed by a colon-bearing nonce, no real s=.
607            ("mf=QQ s=; n=a:b:c;", "dkim2-signature:mf=QQs=;n=a:b:c;\r\n"),
608            // FWS base64 ending in "s=" inside a comma-separated rt= list.
609            (
610                "rt=QQ s=,WWW; s=a:b:CC;",
611                "dkim2-signature:rt=QQs=,WWW;s=a:b:;\r\n",
612            ),
613            // WSP inside the selector and algorithm tokens is deleted.
614            ("s=se l:al g:CC;", "dkim2-signature:s=sel:alg:;\r\n"),
615        ] {
616            let mut out = Vec::new();
617            super::strip_and_canonicalize_signature(value.as_bytes(), &mut out);
618            assert_eq!(
619                String::from_utf8(out).unwrap(),
620                expected,
621                "input: {value:?}"
622            );
623        }
624    }
625}
626
627#[cfg(test)]
628pub(crate) mod test_reverse_path {
629    use std::cell::Cell;
630
631    thread_local! {
632        static REQUIRED: Cell<bool> = const { Cell::new(true) };
633    }
634
635    pub(super) fn required() -> bool {
636        REQUIRED.with(Cell::get)
637    }
638
639    /// Scope guard that relaxes the reverse-path requirement on the current
640    /// thread, restoring it on drop.
641    pub(crate) struct LenientReversePath;
642
643    impl LenientReversePath {
644        pub(crate) fn new() -> Self {
645            REQUIRED.with(|r| r.set(false));
646            LenientReversePath
647        }
648    }
649
650    impl Drop for LenientReversePath {
651        fn drop(&mut self) {
652            REQUIRED.with(|r| r.set(true));
653        }
654    }
655}
656
657#[cfg(test)]
658mod test {
659    use super::{Envelope, MAX_CHAIN_LENGTH, flag_violation};
660    use crate::dkim2::{ChainBinding, Dkim2Signed};
661    use crate::{
662        AuthenticatedMessage, Dkim2Result, Error, MessageAuthenticator,
663        common::{
664            cache::test::DummyCaches, crypto::HashAlgorithm, headers::Header,
665            parse::TxtRecordParser, verify::DomainKey,
666        },
667        dkim2::{Dkim2Error, Flag, MessageHash, MessageInstance, Signature},
668    };
669
670    fn wrap_sigs(s: &[Signature]) -> Vec<Header<'static, Signature>> {
671        s.iter()
672            .map(|x| Header::new(b"".as_slice(), b"".as_slice(), x.clone()))
673            .collect()
674    }
675
676    fn wrap_mis(m: &[MessageInstance]) -> Vec<Header<'static, MessageInstance>> {
677        m.iter()
678            .map(|x| Header::new(b"".as_slice(), b"".as_slice(), x.clone()))
679            .collect()
680    }
681
682    #[test]
683    fn flag_violation_single_pass() {
684        let alg = HashAlgorithm::Sha256;
685        let mi = |m: u32, h: &[u8]| MessageInstance {
686            m,
687            hashes: vec![MessageHash {
688                name: Some(alg),
689                header_hash: h.to_vec(),
690                body_hash: h.to_vec(),
691            }],
692            recipe: None,
693        };
694        let sig = |i: u32, m: u32, flags: Vec<Flag>| Signature {
695            i,
696            m,
697            flags,
698            ..Default::default()
699        };
700
701        let changed = [mi(1, b"a"), mi(2, b"b")];
702        let unchanged = [mi(1, b"a")];
703
704        let donotmodify = [sig(1, 1, vec![Flag::DoNotModify]), sig(2, 2, vec![])];
705        assert_eq!(
706            flag_violation(&wrap_sigs(&donotmodify), &wrap_mis(&changed), alg),
707            Some(Dkim2Error::Modified)
708        );
709        assert_eq!(
710            flag_violation(&wrap_sigs(&donotmodify[..1]), &wrap_mis(&unchanged), alg),
711            None
712        );
713
714        let explode = [
715            sig(1, 1, vec![Flag::DoNotExplode]),
716            sig(2, 1, vec![Flag::Exploded]),
717        ];
718        assert_eq!(
719            flag_violation(&wrap_sigs(&explode), &wrap_mis(&unchanged), alg),
720            Some(Dkim2Error::Exploded)
721        );
722        let explode_before = [
723            sig(1, 1, vec![Flag::Exploded]),
724            sig(2, 1, vec![Flag::DoNotExplode]),
725        ];
726        assert_eq!(
727            flag_violation(&wrap_sigs(&explode_before), &wrap_mis(&unchanged), alg),
728            None
729        );
730    }
731    use std::{
732        path::PathBuf,
733        time::{Duration, Instant},
734    };
735
736    const NOW: u64 = 1740002100;
737
738    fn resource(parts: &[&str]) -> PathBuf {
739        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
740        path.push("resources/dkim2");
741        for part in parts {
742            path.push(part);
743        }
744        path
745    }
746
747    fn load_caches() -> DummyCaches {
748        let caches = DummyCaches::new();
749        let dns = std::fs::read(resource(&["dns.json"])).unwrap();
750        let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
751        let valid_until = Instant::now() + Duration::new(3600, 0);
752        for (domain, selectors) in dns.as_object().unwrap() {
753            for (selector, records) in selectors.as_object().unwrap() {
754                let record = records[0][1].as_str().unwrap();
755                let name = format!("{selector}.{domain}.");
756                caches.txt_add(
757                    name,
758                    DomainKey::parse(record.as_bytes()).unwrap(),
759                    valid_until,
760                );
761            }
762        }
763        caches
764    }
765
766    async fn verify_file<A, R>(
767        resolver: &MessageAuthenticator,
768        caches: &DummyCaches,
769        name: &str,
770        envelope: Envelope<A, R>,
771    ) -> Dkim2Result
772    where
773        A: AsRef<str>,
774        R: IntoIterator<Item: AsRef<str>>,
775    {
776        let raw = std::fs::read(resource(&["expected", name])).unwrap();
777        let message = AuthenticatedMessage::parse(&raw).unwrap();
778        let params = caches.parameters(&message);
779        resolver
780            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
781            .await
782            .result()
783            .clone()
784    }
785
786    fn top_envelope(name: &str) -> (String, Vec<String>) {
787        let raw = std::fs::read(resource(&["expected", name])).unwrap();
788        let message = AuthenticatedMessage::parse(&raw).unwrap();
789        let top = message
790            .dkim2_signatures
791            .iter()
792            .map(|h| &h.header)
793            .max_by_key(|s| s.i)
794            .unwrap();
795        match &top.chain {
796            ChainBinding::Envelope { mail_from, rcpt_to } => (mail_from.clone(), rcpt_to.clone()),
797            ChainBinding::NextDomain(_) => panic!("top signature has nd="),
798        }
799    }
800
801    #[tokio::test]
802    async fn verify_golden_vectors() {
803        let resolver = MessageAuthenticator::new_system_conf().unwrap();
804        let caches = load_caches();
805
806        verify_pass_list(
807            &resolver,
808            &caches,
809            &[
810                "simple-ed25519.eml",
811                "simple-rsa2048.eml",
812                "simple-sel2.eml",
813                "simple-sel3.eml",
814                "multiheader-ed25519.eml",
815                "trailingblank-ed25519.eml",
816                "emptybody-ed25519.eml",
817                "multirecipient-ed25519.eml",
818                "dsn-ed25519.eml",
819                "dupheaders-ed25519.eml",
820            ],
821        )
822        .await;
823
824        let _lenient = super::test_reverse_path::LenientReversePath::new();
825        verify_pass_list(
826            &resolver,
827            &caches,
828            &[
829                "simple-rsa1024.eml",
830                "multihop-header-add.eml",
831                "multihop-body-footer.eml",
832                "multihop-header-replace.eml",
833                "multihop-dup-headers.eml",
834                "multihop-3hop-dup-headers.eml",
835            ],
836        )
837        .await;
838    }
839
840    async fn verify_pass_list(
841        resolver: &MessageAuthenticator,
842        caches: &DummyCaches,
843        names: &[&str],
844    ) {
845        for &name in names {
846            let (mail_from, rcpt_to) = top_envelope(name);
847            let result =
848                verify_file(resolver, caches, name, Envelope::new(&mail_from, &rcpt_to)).await;
849            assert_eq!(result, Dkim2Result::Pass, "vector {name}");
850        }
851    }
852
853    fn prepend(signed: &Dkim2Signed, message: &[u8]) -> Vec<u8> {
854        let mut out = Vec::with_capacity(message.len() + 512);
855        signed.write(&mut out);
856        out.extend_from_slice(message);
857        out
858    }
859
860    #[tokio::test]
861    async fn sign_then_verify_multi_hop() {
862        use crate::{
863            common::crypto::Ed25519Key,
864            dkim2::{Dkim2Signer, Envelope, Hop},
865        };
866        use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
867
868        let load = |domain: &str, selector: &str| {
869            let pem = std::fs::read(resource(&[
870                "keys",
871                &format!("{selector}._domainkey.{domain}.pem"),
872            ]))
873            .unwrap();
874            let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
875                panic!("expected PKCS8 key");
876            };
877            Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
878        };
879
880        let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
881
882        let hop1 = Dkim2Signer::from_key(load("test1.dkim2.com", "ed25519"))
883            .domain("test1.dkim2.com")
884            .selector("ed25519");
885        let sign1 = hop1
886            .sign(
887                &original,
888                Hop::real("sender@test1.dkim2.com", ["list@test2.dkim2.com"]),
889            )
890            .unwrap();
891        let message1 = prepend(&sign1, &original);
892
893        let hop2 = Dkim2Signer::from_key(load("test2.dkim2.com", "ed25519"))
894            .domain("test2.dkim2.com")
895            .selector("ed25519");
896        let sign2 = hop2
897            .sign(
898                &message1,
899                Hop::real("relay@test2.dkim2.com", ["recipient@example.com"]),
900            )
901            .unwrap();
902        let message2 = prepend(&sign2, &message1);
903
904        let resolver = MessageAuthenticator::new_system_conf().unwrap();
905        let caches = load_caches();
906        let message = AuthenticatedMessage::parse(&message2).unwrap();
907        let params = caches.parameters(&message);
908        let envelope = Envelope::new("relay@test2.dkim2.com", ["recipient@example.com"]);
909        let output = resolver
910            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
911            .await;
912        assert_eq!(
913            output.result(),
914            &Dkim2Result::Pass,
915            "{:?}",
916            output.failure_reason()
917        );
918        assert_eq!(output.chain().len(), 2);
919    }
920
921    #[tokio::test]
922    async fn sign_then_verify_imaginary_hop() {
923        use crate::{
924            common::crypto::Ed25519Key,
925            dkim2::{Dkim2Signer, Envelope, Hop},
926        };
927        use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
928
929        let load = |domain: &str| {
930            let pem = std::fs::read(resource(&[
931                "keys",
932                &format!("ed25519._domainkey.{domain}.pem"),
933            ]))
934            .unwrap();
935            let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
936                panic!("expected PKCS8 key");
937            };
938            Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
939        };
940        let signer = |domain: &'static str| {
941            Dkim2Signer::from_key(load(domain))
942                .domain(domain)
943                .selector("ed25519")
944        };
945
946        // test1 delivers to test2, which internally hands the message over to
947        // test3 without an SMTP transaction, which then delivers to example.com
948        let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
949        let sign1 = signer("test1.dkim2.com")
950            .sign(
951                &original,
952                Hop::real("sender@test1.dkim2.com", ["list@test2.dkim2.com"]),
953            )
954            .unwrap();
955        let message1 = prepend(&sign1, &original);
956
957        let sign2 = signer("test2.dkim2.com")
958            .sign(&message1, Hop::imaginary("test3.dkim2.com"))
959            .unwrap();
960        let message2 = prepend(&sign2, &message1);
961        assert!(matches!(sign2.signature.chain, ChainBinding::NextDomain(_)));
962
963        let sign3 = signer("test3.dkim2.com")
964            .sign(
965                &message2,
966                Hop::real("relay@test3.dkim2.com", ["recipient@example.com"]),
967            )
968            .unwrap();
969        let message3 = prepend(&sign3, &message2);
970
971        let resolver = MessageAuthenticator::new_system_conf().unwrap();
972        let caches = load_caches();
973        let message = AuthenticatedMessage::parse(&message3).unwrap();
974        let params = caches.parameters(&message);
975        let envelope = Envelope::new("relay@test3.dkim2.com", ["recipient@example.com"]);
976        let output = resolver
977            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
978            .await;
979        assert_eq!(
980            output.result(),
981            &Dkim2Result::Pass,
982            "{:?}",
983            output.failure_reason()
984        );
985        assert_eq!(output.chain().len(), 3);
986    }
987
988    #[tokio::test]
989    async fn verify_rejects_imaginary_hop_outside_custody() {
990        use crate::{
991            common::crypto::Ed25519Key,
992            dkim2::{Dkim2Signer, Envelope, Hop},
993        };
994        use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
995
996        let load = |domain: &str| {
997            let pem = std::fs::read(resource(&[
998                "keys",
999                &format!("ed25519._domainkey.{domain}.pem"),
1000            ]))
1001            .unwrap();
1002            let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
1003                panic!("expected PKCS8 key");
1004            };
1005            Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
1006        };
1007        let signer = |domain: &'static str| {
1008            Dkim2Signer::from_key(load(domain))
1009                .domain(domain)
1010                .selector("ed25519")
1011        };
1012
1013        // test4 was never a recipient of the previous hop, so its nd= signature
1014        // is not a continuation of the chain of custody
1015        let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
1016        let sign1 = signer("test1.dkim2.com")
1017            .sign(
1018                &original,
1019                Hop::real("sender@test1.dkim2.com", ["list@test2.dkim2.com"]),
1020            )
1021            .unwrap();
1022        let message1 = prepend(&sign1, &original);
1023
1024        let sign2 = signer("test4.dkim2.com")
1025            .sign(&message1, Hop::imaginary("test3.dkim2.com"))
1026            .unwrap();
1027        let message2 = prepend(&sign2, &message1);
1028
1029        let sign3 = signer("test3.dkim2.com")
1030            .sign(
1031                &message2,
1032                Hop::real("relay@test3.dkim2.com", ["recipient@example.com"]),
1033            )
1034            .unwrap();
1035        let message3 = prepend(&sign3, &message2);
1036
1037        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1038        let caches = load_caches();
1039        let message = AuthenticatedMessage::parse(&message3).unwrap();
1040        let params = caches.parameters(&message);
1041        let envelope = Envelope::new("relay@test3.dkim2.com", ["recipient@example.com"]);
1042        let result = resolver
1043            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1044            .await;
1045
1046        assert_eq!(
1047            result.result(),
1048            &Dkim2Result::PermError(Error::Dkim2(Dkim2Error::CustodyBreak(2))),
1049            "{:?}",
1050            result.failure_reason()
1051        );
1052    }
1053
1054    #[tokio::test]
1055    async fn sign_multi_algorithm_then_verify() {
1056        use crate::{
1057            common::crypto::{Algorithm, Ed25519Key, RsaKey, Sha256},
1058            dkim2::{Dkim2Signer, Envelope, Hop},
1059        };
1060        use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
1061
1062        let load_ed = |domain: &str, selector: &str| {
1063            let pem = std::fs::read(resource(&[
1064                "keys",
1065                &format!("{selector}._domainkey.{domain}.pem"),
1066            ]))
1067            .unwrap();
1068            let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
1069                panic!("expected PKCS8 key");
1070            };
1071            Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
1072        };
1073        let load_rsa = |domain: &str, selector: &str| {
1074            let pem = std::fs::read(resource(&[
1075                "keys",
1076                &format!("{selector}._domainkey.{domain}.pem"),
1077            ]))
1078            .unwrap();
1079            RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(&pem).unwrap()).unwrap()
1080        };
1081
1082        let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
1083
1084        let signed = Dkim2Signer::from_key(load_ed("test1.dkim2.com", "ed25519"))
1085            .domain("test1.dkim2.com")
1086            .selector("ed25519")
1087            .additional_key(load_rsa("test1.dkim2.com", "sel1"), "sel1")
1088            .sign(
1089                &original,
1090                Hop::real("sender@test1.dkim2.com", ["recipient@example.com"]),
1091            )
1092            .unwrap();
1093
1094        assert_eq!(signed.signature.s.len(), 2);
1095        assert_eq!(signed.signature.s[0].selector, "ed25519");
1096        assert_eq!(signed.signature.s[0].a, Algorithm::Ed25519Sha256);
1097        assert_eq!(signed.signature.s[1].selector, "sel1");
1098        assert_eq!(signed.signature.s[1].a, Algorithm::RsaSha256);
1099
1100        let message = prepend(&signed, &original);
1101        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1102        let caches = load_caches();
1103        let parsed = AuthenticatedMessage::parse(&message).unwrap();
1104        let params = caches.parameters(&parsed);
1105        let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1106        let output = resolver
1107            .verify_dkim2_(&parsed, envelope, params.cache_txt, NOW, true)
1108            .await;
1109        assert_eq!(
1110            output.result(),
1111            &Dkim2Result::Pass,
1112            "{:?}",
1113            output.failure_reason()
1114        );
1115    }
1116
1117    #[tokio::test]
1118    async fn verify_rejects_wrong_envelope() {
1119        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1120        let caches = load_caches();
1121        let envelope = Envelope::new("attacker@evil.example", ["recipient@example.com"]);
1122        let result = verify_file(&resolver, &caches, "simple-ed25519.eml", envelope).await;
1123        assert!(
1124            matches!(result, Dkim2Result::PermError(_)),
1125            "got {result:?}"
1126        );
1127    }
1128
1129    #[tokio::test]
1130    async fn verify_rejects_long_chains() {
1131        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1132        let caches = load_caches();
1133
1134        for (count, expect_too_long) in [
1135            (MAX_CHAIN_LENGTH, false),
1136            (MAX_CHAIN_LENGTH + 1, true),
1137            (MAX_CHAIN_LENGTH * 4, true),
1138        ] {
1139            let mut raw = Vec::new();
1140            for i in 1..=count {
1141                raw.extend_from_slice(
1142                    format!(
1143                        "DKIM2-Signature: i={i}; m={i}; t={NOW}; d=ex{i}.com; nd=ex{}.com; \
1144                         s=sel:rsa-sha256:QQ==;\r\n",
1145                        i + 1
1146                    )
1147                    .as_bytes(),
1148                );
1149            }
1150            raw.extend_from_slice(b"From: sender@test1.dkim2.com\r\n\r\nHello\r\n");
1151
1152            let message = AuthenticatedMessage::parse(&raw).unwrap();
1153            assert_eq!(message.dkim2_signatures.len(), count);
1154
1155            let params = caches.parameters(&message);
1156            let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1157            let result = resolver
1158                .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1159                .await;
1160
1161            assert_eq!(
1162                matches!(
1163                    result.result(),
1164                    Dkim2Result::PermError(Error::Dkim2(Dkim2Error::ChainTooLong))
1165                ),
1166                expect_too_long,
1167                "count={count} got {:?}",
1168                result.result()
1169            );
1170        }
1171    }
1172
1173    #[tokio::test]
1174    async fn verify_rejects_tampered_body() {
1175        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1176        let caches = load_caches();
1177        let raw = std::fs::read(resource(&["expected", "simple-ed25519.eml"])).unwrap();
1178        let mut tampered = raw.clone();
1179        let pos = tampered.windows(5).position(|w| w == b"Hello").unwrap();
1180        tampered[pos] = b'J';
1181        let message = AuthenticatedMessage::parse(&tampered).unwrap();
1182        let params = caches.parameters(&message);
1183        let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1184        let result = resolver
1185            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1186            .await;
1187        assert!(
1188            matches!(result.result(), Dkim2Result::Fail(_)),
1189            "got {:?}",
1190            result.result()
1191        );
1192    }
1193
1194    #[tokio::test]
1195    async fn verify_rejects_tampered_header() {
1196        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1197        let caches = load_caches();
1198        let raw = std::fs::read(resource(&["expected", "simple-ed25519.eml"])).unwrap();
1199        let mut tampered = raw.clone();
1200        let pos = tampered.windows(6).position(|w| w == b"Simple").unwrap();
1201        tampered[pos] = b'X';
1202        let message = AuthenticatedMessage::parse(&tampered).unwrap();
1203        let params = caches.parameters(&message);
1204        let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1205        let result = resolver
1206            .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1207            .await;
1208        assert!(
1209            matches!(
1210                result.result(),
1211                Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(_)))
1212            ),
1213            "got {:?}",
1214            result.result()
1215        );
1216    }
1217
1218    #[tokio::test]
1219    async fn verify_rejects_rcpt_not_in_rt() {
1220        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1221        let caches = load_caches();
1222        let envelope = Envelope::new("sender@test1.dkim2.com", ["someone-else@example.com"]);
1223        let result = verify_file(&resolver, &caches, "simple-ed25519.eml", envelope).await;
1224        assert!(
1225            matches!(
1226                result,
1227                Dkim2Result::PermError(Error::Dkim2(Dkim2Error::RcptToMismatch(_)))
1228            ),
1229            "got {result:?}"
1230        );
1231    }
1232
1233    fn state_matches(expected: &str, result: &Dkim2Result) -> bool {
1234        match expected {
1235            "pass" => matches!(result, Dkim2Result::Pass),
1236            "fail" => matches!(result, Dkim2Result::Fail(_)),
1237            "permerror" => matches!(result, Dkim2Result::PermError(_)),
1238            "temperror" => matches!(result, Dkim2Result::TempError(_)),
1239            other => panic!("unknown expected state {other}"),
1240        }
1241    }
1242
1243    #[tokio::test]
1244    async fn test_vectors() {
1245        let resolver = MessageAuthenticator::new_system_conf().unwrap();
1246        let caches = load_caches();
1247
1248        let cases = std::fs::read(resource(&["cases.json"])).unwrap();
1249        let cases: serde_json::Value = serde_json::from_slice(&cases).unwrap();
1250        let cases = cases.as_array().unwrap();
1251        assert!(!cases.is_empty(), "no imported vectors found");
1252
1253        let mut failures = Vec::new();
1254        for case in cases {
1255            let name = case["name"].as_str().unwrap();
1256            let expected = case["expected"].as_str().unwrap();
1257            let file = case["file"].as_str().unwrap();
1258            let mail_from = case["mail_from"].as_str().unwrap().to_string();
1259            let rcpt_to: Vec<String> = case["rcpt_to"]
1260                .as_array()
1261                .unwrap()
1262                .iter()
1263                .map(|r| r.as_str().unwrap().to_string())
1264                .collect();
1265
1266            let now = case["now"]
1267                .as_u64()
1268                .expect("vector manifest must carry now");
1269            let strict = case["strict"].as_bool().unwrap_or(true);
1270
1271            let raw = std::fs::read(resource(&["expected", file])).unwrap();
1272            let Some(message) = AuthenticatedMessage::parse(&raw) else {
1273                failures.push(format!("{name}: message failed to parse"));
1274                continue;
1275            };
1276            let params = caches.parameters(&message);
1277            let envelope = Envelope::new(&mail_from, &rcpt_to);
1278            let lenient = (!strict).then(super::test_reverse_path::LenientReversePath::new);
1279            let output = resolver
1280                .verify_dkim2_(&message, envelope, params.cache_txt, now, true)
1281                .await;
1282            drop(lenient);
1283            if !state_matches(expected, output.result()) {
1284                failures.push(format!(
1285                    "{name}: expected {expected}, got {:?} ({:?})",
1286                    output.result(),
1287                    output.failure_reason()
1288                ));
1289            }
1290        }
1291
1292        assert!(
1293            failures.is_empty(),
1294            "{} of {} vectors diverged:\n{}",
1295            failures.len(),
1296            cases.len(),
1297            failures.join("\n")
1298        );
1299    }
1300}