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