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
7//! DKIM2 chain verification (§13 and §14) through
8//! [`MessageAuthenticator::verify_dkim2`] and
9//! [`MessageAuthenticator::verify_dkim2_provisionally`].
10
11use super::{
12    ChainBinding, ChainLink, Dkim2Error, Dkim2Hash, Dkim2Output, MessageHash, MessageInstance,
13    Signature, SignatureValue, Verification,
14    recipe::{BodyRecipe, apply_body_recipe, body_lines, headers_len},
15    sign::Envelope,
16};
17use crate::dns::DnsCache;
18use crate::{
19    AuthenticatedMessage, Dkim2Result, DnsError, Error, MessageAuthenticator, Parameters,
20    ResolverCache, TxtRecord,
21    crypto::{Algorithm, CryptoError},
22    dkim::{DkimError, DomainKey},
23    dkim2::parse::is_domain,
24    dkim2::{
25        canonicalize::{CanonicalizedHeaderWriter, Digests, body_digests, header_digests},
26        sign::now,
27    },
28    dns::to_a_label,
29    headers::{Header, HeaderIterator, Writer},
30};
31use mail_parser::{Address, HeaderForm};
32use outcome::{Failure, Tally};
33use std::borrow::Cow;
34
35pub(crate) mod outcome;
36
37const MAX_AGE: u64 = 14 * 86400;
38const MAX_FUTURE: u64 = 86400;
39const MAX_CHAIN_LENGTH: usize = 50;
40const MIN_RSA_BITS: usize = 1024;
41
42impl MessageAuthenticator {
43    /// Verifies the DKIM2 signature chain of an RFC 5322 message: whether it
44    /// is a Fully Verified DKIM2 Message (§13 and §14).
45    ///
46    /// `params` wraps the parsed message, optionally with a DNS cache (see
47    /// [`Parameters`]); a plain `&AuthenticatedMessage` also works.
48    /// `envelope` is the SMTP envelope the message was received with. It
49    /// must match the most recent signature: `MAIL FROM` equals its `mf=`
50    /// and every `RCPT TO` appears in its `rt=`.
51    ///
52    /// The verifier checks, in order, the syntax and numbering of every
53    /// `DKIM2-Signature` and `Message-Instance` (§14.2), the signature
54    /// timestamps (at most 14 days old and at most one day in the future,
55    /// §14.3), the envelope binding, the `mf=`/`d=` match of every signature
56    /// and the chain of custody (§14.4), the signatures of every
57    /// `DKIM2-Signature` (§14.5 and §14.6), the hashes of every instance,
58    /// applying recipes to recreate earlier revisions (§14.7), and that every
59    /// `From` header field, current or recreated, is Authentic (§11 and
60    /// §14.8). Signatures and hashes are combined over their `,` and `|`
61    /// groups: a group passes when all its entries pass, and the tag passes
62    /// when any group does. It stops once the result is known. Domains are
63    /// compared as A-labels (§18).
64    ///
65    /// For each `s=` entry it evaluates it performs one DNS TXT lookup for
66    /// the public key at `<selector>._domainkey.<d>`, served from the cache
67    /// when one is given. Entries of a group that already failed and groups
68    /// after one that passed are not looked up.
69    ///
70    /// The returned [`Dkim2Output`] holds one of:
71    ///
72    /// - [`Dkim2Result::Pass`]: the whole chain verified. The output lists
73    ///   every hop in [`Dkim2Output::chain`].
74    /// - [`Dkim2Result::Fail`]: verification completed but the message is not
75    ///   a Fully Verified DKIM2 Message: a signature, header hash or body hash
76    ///   did not match, a timestamp is out of range, the envelope or chain of
77    ///   custody does not match, a `From` header field is not Authentic, or a
78    ///   null body recipe prevents checking earlier body hashes (§13).
79    /// - [`Dkim2Result::PermError`]: a DKIM2 header field is malformed,
80    ///   incomplete or missing, the numbering has a gap, the chain is longer
81    ///   than 50 hops, a signature or hash algorithm is not implemented, or a
82    ///   public key is missing, malformed, duplicated, revoked or of the wrong
83    ///   type, and no group passed.
84    /// - [`Dkim2Result::TempError`]: a public key lookup failed with a
85    ///   temporary DNS error and no group passed.
86    /// - [`Dkim2Result::None`]: the message has no `DKIM2-Signature` and no
87    ///   `Message-Instance` header field.
88    ///
89    /// # Example
90    ///
91    /// ```rust,no_run
92    /// use mail_auth::{AuthenticatedMessage, Dkim2Result, MessageAuthenticator, dkim2::Envelope};
93    ///
94    /// # async fn run(raw_message: &[u8]) {
95    /// let authenticator = MessageAuthenticator::new_cloudflare_tls().unwrap();
96    /// let message = AuthenticatedMessage::parse(raw_message).unwrap();
97    ///
98    /// let envelope = Envelope::new("sender@example.com", ["recipient@example.org"]);
99    /// let output = authenticator.verify_dkim2(&message, envelope).await;
100    ///
101    /// match output.result() {
102    ///     Dkim2Result::Pass => {
103    ///         for link in output.chain() {
104    ///             println!("hop {} signed by {}", link.signature.i, link.signature.d);
105    ///         }
106    ///     }
107    ///     Dkim2Result::None => println!("not DKIM2 signed"),
108    ///     _ => println!("DKIM2 failed: {:?}", output.error()),
109    /// }
110    /// # }
111    /// ```
112    pub async fn verify_dkim2<'x, C, A, R>(
113        &self,
114        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
115        envelope: Envelope<A, R>,
116    ) -> Dkim2Output<'x>
117    where
118        C: DnsCache + 'x,
119        A: AsRef<str>,
120        R: IntoIterator<Item: AsRef<str>>,
121    {
122        let params = params.into();
123        self.verify_dkim2_(
124            params.input,
125            envelope,
126            params.txt_cache(),
127            now(),
128            true,
129            Verification::Full,
130        )
131        .await
132    }
133
134    /// Checks whether a message is Provisionally Verified (§13.2): the
135    /// initial assessment a receiver may make before accepting delivery,
136    /// and the precondition for later sending a DSN about the message.
137    ///
138    /// Performs the header field validity, timestamp and envelope checks of
139    /// [`verify_dkim2`](Self::verify_dkim2), then verifies only the most
140    /// recent signature and the hashes of the most recent `Message-Instance`.
141    /// It does not check the chain of custody of earlier hops, earlier
142    /// signatures or hashes, or whether `From` header fields are Authentic.
143    /// A most recent signature with `nd=` is a [`Dkim2Result::Fail`] (§14.4).
144    ///
145    /// A passing result is reported with [`Verification::Provisional`], its
146    /// [`Dkim2Output::chain`] holds only the most recent signature, and it is
147    /// neither a proof of validity for [`Dkim2Signer`](super::Dkim2Signer)
148    /// nor an authenticated identity for DMARC or Authentication-Results.
149    pub async fn verify_dkim2_provisionally<'x, C, A, R>(
150        &self,
151        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
152        envelope: Envelope<A, R>,
153    ) -> Dkim2Output<'x>
154    where
155        C: DnsCache + 'x,
156        A: AsRef<str>,
157        R: IntoIterator<Item: AsRef<str>>,
158    {
159        let params = params.into();
160        self.verify_dkim2_(
161            params.input,
162            envelope,
163            params.txt_cache(),
164            now(),
165            true,
166            Verification::Provisional,
167        )
168        .await
169    }
170
171    pub(crate) async fn verify_dkim2_<'x, TXT, A, R>(
172        &self,
173        message: &'x AuthenticatedMessage<'x>,
174        envelope: Envelope<A, R>,
175        cache_txt: Option<&TXT>,
176        now: u64,
177        body_present: bool,
178        verification: Verification,
179    ) -> Dkim2Output<'x>
180    where
181        TXT: ResolverCache<Box<str>, TxtRecord>,
182        A: AsRef<str>,
183        R: IntoIterator<Item: AsRef<str>>,
184    {
185        let result = self
186            .verify_chain(
187                message,
188                envelope,
189                cache_txt,
190                now,
191                body_present,
192                verification,
193            )
194            .await;
195        let signatures = message.dkim2_signatures.as_slice();
196        let instances = message.dkim2_instances.as_slice();
197        let verified = match verification {
198            Verification::Full => signatures,
199            Verification::Provisional => signatures
200                .len()
201                .checked_sub(1)
202                .and_then(|top| signatures.get(top..))
203                .unwrap_or_default(),
204        };
205        let chain = match result {
206            Ok(()) => verified
207                .iter()
208                .map(|sig_header| ChainLink {
209                    signature: &sig_header.header,
210                    instance: sig_header
211                        .header
212                        .m
213                        .checked_sub(1)
214                        .and_then(|index| instances.get(index as usize))
215                        .map(|h| &h.header),
216                    result: Dkim2Result::Pass,
217                    custody_ok: true,
218                })
219                .collect(),
220            Err(_) => Vec::new(),
221        };
222        Dkim2Output {
223            result: match result {
224                Ok(()) => Dkim2Result::Pass,
225                Err(result) => result,
226            },
227            chain,
228            verification,
229            raw_message: message.raw_message,
230            signature_fields: signatures,
231            instance_fields: instances,
232        }
233    }
234
235    async fn verify_chain<TXT, A, R>(
236        &self,
237        message: &AuthenticatedMessage<'_>,
238        envelope: Envelope<A, R>,
239        cache_txt: Option<&TXT>,
240        now: u64,
241        body_present: bool,
242        verification: Verification,
243    ) -> Result<(), Dkim2Result>
244    where
245        TXT: ResolverCache<Box<str>, TxtRecord>,
246        A: AsRef<str>,
247        R: IntoIterator<Item: AsRef<str>>,
248    {
249        if message.has_dkim2_errors {
250            for header in &message.errors {
251                let name = header.name.trim_ascii();
252
253                if name.eq_ignore_ascii_case(b"dkim2-signature")
254                    || name.eq_ignore_ascii_case(b"message-instance")
255                {
256                    return Err(Dkim2Result::from(header.header.clone()));
257                }
258            }
259        }
260
261        let signatures = message.dkim2_signatures.as_slice();
262        let instances = message.dkim2_instances.as_slice();
263        if signatures.is_empty() {
264            return Err(if instances.is_empty() {
265                Dkim2Result::None
266            } else {
267                perm_error(Dkim2Error::SignatureMissing(1))
268            });
269        } else if signatures.len() > MAX_CHAIN_LENGTH || instances.len() > MAX_CHAIN_LENGTH {
270            return Err(perm_error(Dkim2Error::ChainTooLong));
271        }
272
273        check_numbering(signatures, instances).map_err(perm_error)?;
274        check_timestamps(signatures, now).map_err(fail)?;
275
276        let Some((top, previous)) = signatures.split_last() else {
277            return Err(perm_error(Dkim2Error::SignatureMissing(1)));
278        };
279        let top = &top.header;
280        check_envelope(top, envelope).map_err(fail)?;
281
282        match verification {
283            Verification::Full => {
284                for header in signatures {
285                    check_mail_from_domain(&header.header).map_err(fail)?;
286                }
287                for pair in signatures.windows(2) {
288                    if let [previous, current] = pair {
289                        check_custody(&previous.header, &current.header).map_err(fail)?;
290                    }
291                }
292            }
293            Verification::Provisional => check_mail_from_domain(top).map_err(fail)?,
294        }
295
296        let checked = match verification {
297            Verification::Full => signatures,
298            Verification::Provisional => signatures.get(previous.len()..).unwrap_or_default(),
299        };
300        match verification {
301            Verification::Full => check_instances(message, signatures, instances, body_present),
302            Verification::Provisional => instances.last().map_or(Ok(()), |header| {
303                StateHashes::default().check(
304                    &header.header,
305                    &message.headers,
306                    body_present.then(|| message.raw_body()),
307                )
308            }),
309        }
310        .map_err(Dkim2Result::from)?;
311
312        let mut input = Vec::with_capacity(256 * (instances.len() + signatures.len()));
313        let mut temp_error = None;
314        for sig_header in checked {
315            match self
316                .check_signature(sig_header, signatures, instances, cache_txt, &mut input)
317                .await
318            {
319                Ok(()) => {}
320                Err(Failure::TempError(error)) => {
321                    temp_error.get_or_insert(error);
322                }
323                Err(failure) => return Err(failure.into()),
324            }
325        }
326        temp_error.map_or(Ok(()), |error| Err(Failure::TempError(error).into()))
327    }
328
329    async fn check_signature<TXT>(
330        &self,
331        sig_header: &Header<'_, Signature>,
332        signatures: &[Header<'_, Signature>],
333        instances: &[Header<'_, MessageInstance>],
334        cache_txt: Option<&TXT>,
335        input: &mut Vec<u8>,
336    ) -> Result<(), Failure>
337    where
338        TXT: ResolverCache<Box<str>, TxtRecord>,
339    {
340        let signature = &sig_header.header;
341
342        input.clear();
343        for (name, value) in instances
344            .iter()
345            .filter(|h| h.header.m <= signature.m)
346            .map(|h| (h.name, h.value))
347            .chain(
348                signatures
349                    .iter()
350                    .take_while(|h| h.header.i < signature.i)
351                    .map(|h| (h.name, h.value)),
352            )
353        {
354            let mut w = CanonicalizedHeaderWriter::new(input, name);
355            w.write(value);
356            w.finalize();
357        }
358        strip_and_canonicalize_signature(sig_header.value, input);
359
360        let mut groups = Tally::default();
361        for group in signature.s.chunk_by(|a, b| a.group == b.group) {
362            let mut entries = Tally::default();
363            for value in group {
364                entries.record(
365                    self.check_signature_value(signature, value, input, cache_txt)
366                        .await,
367                );
368                if entries.failed() {
369                    break;
370                }
371            }
372            groups.record(entries.all());
373            if groups.passed() {
374                return Ok(());
375            }
376        }
377        groups.any(Dkim2Error::NoSignatureGroupPassed(signature.i))
378    }
379
380    async fn check_signature_value<TXT>(
381        &self,
382        signature: &Signature,
383        value: &SignatureValue,
384        input: &[u8],
385        cache_txt: Option<&TXT>,
386    ) -> Result<(), Failure>
387    where
388        TXT: ResolverCache<Box<str>, TxtRecord>,
389    {
390        let key_error = |error: fn(u32, Box<str>) -> Dkim2Error| {
391            error(signature.i, value.selector.as_str().into())
392        };
393        let Some(algorithm) = value.a.known() else {
394            return Err(Failure::PermError(key_error(|i, selector| {
395                Dkim2Error::UnknownAlgorithm { i, selector }
396            })));
397        };
398        let key = match self
399            .txt_lookup::<DomainKey>(
400                format!("{}._domainkey.{}.", value.selector, signature.d),
401                cache_txt,
402            )
403            .await
404        {
405            Ok(key) => key,
406            Err(Error::Dns(DnsError::Resolver(_))) => {
407                return Err(Failure::TempError(key_error(|i, selector| {
408                    Dkim2Error::PublicKeyFetch { i, selector }
409                })));
410            }
411            Err(err) => {
412                return Err(Failure::PermError(key_error(match err {
413                    Error::Dkim(DkimError::PublicKeyRevoked) => {
414                        |i, selector| Dkim2Error::PublicKeyRevoked { i, selector }
415                    }
416                    Error::Dns(DnsError::RecordNotFound(_)) => {
417                        |i, selector| Dkim2Error::PublicKeyMissing { i, selector }
418                    }
419                    Error::Dkim(DkimError::UnsupportedKeyType) => {
420                        |i, selector| Dkim2Error::PublicKeyAlgorithmMismatch { i, selector }
421                    }
422                    Error::Crypto(CryptoError::MalformedPublicKey) => {
423                        |i, selector| Dkim2Error::PublicKeyMalformed { i, selector }
424                    }
425                    _ => |i, selector| Dkim2Error::PublicKeySyntax { i, selector },
426                })));
427            }
428        };
429
430        if key.has_multiple_records() {
431            return Err(Failure::PermError(key_error(|i, selector| {
432                Dkim2Error::PublicKeyMultiple { i, selector }
433            })));
434        } else if matches!(algorithm, Algorithm::RsaSha256)
435            && key.p.public_key_bits() < MIN_RSA_BITS
436        {
437            return Err(Failure::PermError(key_error(|i, selector| {
438                Dkim2Error::PublicKeyMalformed { i, selector }
439            })));
440        }
441
442        match key.p.verify_bytes(input, &value.b, algorithm) {
443            Ok(()) => Ok(()),
444            Err(Error::Crypto(CryptoError::IncompatibleAlgorithms)) => {
445                Err(Failure::PermError(key_error(|i, selector| {
446                    Dkim2Error::PublicKeyAlgorithmMismatch { i, selector }
447                })))
448            }
449            Err(_) => Err(Failure::Fail(key_error(|i, selector| {
450                Dkim2Error::IncorrectSignature { i, selector }
451            }))),
452        }
453    }
454}
455
456#[cold]
457fn perm_error(error: Dkim2Error) -> Dkim2Result {
458    Dkim2Result::PermError(Error::Dkim2(error))
459}
460
461#[cold]
462fn fail(error: Dkim2Error) -> Dkim2Result {
463    Dkim2Result::Fail(Error::Dkim2(error))
464}
465
466fn check_timestamps(signatures: &[Header<'_, Signature>], now: u64) -> Result<(), Dkim2Error> {
467    for header in signatures {
468        let signature = &header.header;
469        if now > signature.t && now - signature.t > MAX_AGE {
470            return Err(Dkim2Error::SignatureExpired(signature.i));
471        } else if signature.t > now.saturating_add(MAX_FUTURE) {
472            return Err(Dkim2Error::SignatureFromFuture(signature.i));
473        }
474    }
475    Ok(())
476}
477
478fn check_numbering(
479    signatures: &[Header<'_, Signature>],
480    instances: &[Header<'_, MessageInstance>],
481) -> Result<(), Dkim2Error> {
482    for (expected, header) in (1u32..).zip(signatures) {
483        let signature = &header.header;
484        if signature.i != expected {
485            return Err(Dkim2Error::SequenceGap(expected));
486        }
487        if let ChainBinding::Envelope { mail_from, rcpt_to } = &signature.chain
488            && require_reverse_path()
489            && !(is_reverse_path(mail_from) && rcpt_to.iter().all(|r| is_reverse_path(r)))
490        {
491            return Err(Dkim2Error::SignatureSyntax(signature.i));
492        }
493    }
494
495    for (expected, header) in (1u32..).zip(instances) {
496        if header.header.m != expected {
497            return Err(Dkim2Error::InstanceMissing(expected));
498        }
499    }
500
501    let highest_m = instances.last().map_or(0, |h| h.header.m);
502    let mut previous_m = 0;
503    for header in signatures {
504        let signature = &header.header;
505        if signature.m == 0 || signature.m > highest_m {
506            return Err(Dkim2Error::InstanceMissing(signature.m));
507        } else if signature.m < previous_m {
508            return Err(Dkim2Error::SignatureSyntax(signature.i));
509        }
510        previous_m = signature.m;
511    }
512    if highest_m > previous_m {
513        return Err(Dkim2Error::InstanceNotSigned(highest_m));
514    }
515
516    Ok(())
517}
518
519fn check_envelope<A, R>(top: &Signature, envelope: Envelope<A, R>) -> Result<(), Dkim2Error>
520where
521    A: AsRef<str>,
522    R: IntoIterator<Item: AsRef<str>>,
523{
524    match &top.chain {
525        ChainBinding::Envelope { mail_from, rcpt_to } => {
526            let envelope_from = envelope.mail_from.as_ref();
527            if !address_matches(envelope_from, mail_from) {
528                return Err(Dkim2Error::MailFromMismatch {
529                    i: top.i,
530                    value: envelope_from.into(),
531                });
532            }
533            for rcpt in envelope.rcpt_to {
534                let rcpt = rcpt.as_ref();
535                if !rcpt_to.iter().any(|r| address_matches(rcpt, r)) {
536                    return Err(Dkim2Error::RcptToMismatch {
537                        i: top.i,
538                        value: rcpt.into(),
539                    });
540                }
541            }
542            Ok(())
543        }
544        ChainBinding::NextDomain(_) => Err(Dkim2Error::SignatureTagUnexpected {
545            i: top.i,
546            tag: "nd",
547        }),
548    }
549}
550
551pub(crate) fn check_mail_from_domain(signature: &Signature) -> Result<(), Dkim2Error> {
552    match &signature.chain {
553        ChainBinding::Envelope { mail_from, .. }
554            if !is_null_path(mail_from)
555                && !relaxed_domain_match(local_and_domain(mail_from).1, &signature.d) =>
556        {
557            Err(Dkim2Error::MailFromDomainMismatch(signature.i))
558        }
559        _ => Ok(()),
560    }
561}
562
563pub(crate) fn check_custody(previous: &Signature, current: &Signature) -> Result<(), Dkim2Error> {
564    match (&previous.chain, &current.chain) {
565        (ChainBinding::NextDomain(next_domain), _) => {
566            if domain_eq(next_domain, &current.d) {
567                Ok(())
568            } else {
569                Err(Dkim2Error::NextDomainMismatch(current.i))
570            }
571        }
572        (ChainBinding::Envelope { rcpt_to, .. }, ChainBinding::Envelope { mail_from, .. })
573            if !is_null_path(mail_from) =>
574        {
575            let mail_from_domain = local_and_domain(mail_from).1;
576            if rcpt_to
577                .iter()
578                .any(|rcpt| relaxed_domain_match(mail_from_domain, local_and_domain(rcpt).1))
579            {
580                Ok(())
581            } else {
582                Err(Dkim2Error::CustodyMailFromMismatch {
583                    i: current.i,
584                    previous: previous.i,
585                })
586            }
587        }
588        (ChainBinding::Envelope { rcpt_to, .. }, _) => {
589            if rcpt_to
590                .iter()
591                .any(|rcpt| relaxed_domain_match(local_and_domain(rcpt).1, &current.d))
592            {
593                Ok(())
594            } else {
595                Err(Dkim2Error::CustodyDomainMismatch {
596                    i: current.i,
597                    previous: previous.i,
598                })
599            }
600        }
601    }
602}
603
604#[derive(Default)]
605pub(crate) struct StateHashes {
606    header: Digests,
607    body: Digests,
608}
609
610impl StateHashes {
611    pub(crate) fn check(
612        &mut self,
613        instance: &MessageInstance,
614        headers: &[(&[u8], &[u8])],
615        body: Option<&[u8]>,
616    ) -> Result<(), Failure> {
617        let mut header_wanted = [false; Dkim2Hash::COUNT];
618        let mut body_wanted = [false; Dkim2Hash::COUNT];
619        for algorithm in instance.hashes.iter().filter_map(|hash| hash.name.known()) {
620            let index = algorithm.index();
621            for (wanted, cached) in [
622                (&mut header_wanted, &self.header),
623                (&mut body_wanted, &self.body),
624            ] {
625                if let (Some(wanted), Some(None)) = (wanted.get_mut(index), cached.get(index)) {
626                    *wanted = true;
627                }
628            }
629        }
630        if body.is_none() {
631            body_wanted = [false; Dkim2Hash::COUNT];
632        }
633        fill(
634            &mut self.header,
635            header_digests(headers.iter().copied(), header_wanted),
636        );
637        if let Some(body) = body.filter(|_| body_wanted.contains(&true)) {
638            fill(&mut self.body, body_digests(body, body_wanted));
639        }
640
641        let mut groups = Tally::default();
642        for group in instance.hashes.chunk_by(|a, b| a.group == b.group) {
643            let mut entries = Tally::default();
644            for hash in group {
645                entries.record(self.check_hash(instance.m, hash, body.is_some()));
646                if entries.failed() {
647                    break;
648                }
649            }
650            groups.record(entries.all());
651            if groups.passed() {
652                return Ok(());
653            }
654        }
655        groups.any(Dkim2Error::NoHashGroupPassed(instance.m))
656    }
657
658    fn check_hash(&self, m: u32, hash: &MessageHash, body_present: bool) -> Result<(), Failure> {
659        let Some(algorithm) = hash.name.known() else {
660            return Err(Failure::PermError(Dkim2Error::UnknownHash {
661                m,
662                name: hash.name.as_str().into(),
663            }));
664        };
665        let index = algorithm.index();
666        let matches = |digests: &Digests, expected: &[u8]| {
667            digests
668                .get(index)
669                .and_then(Option::as_ref)
670                .is_some_and(|computed| computed.as_ref() == expected)
671        };
672        if !matches(&self.header, &hash.header_hash) {
673            Err(Failure::Fail(Dkim2Error::HeaderHashMismatch {
674                m,
675                hash: algorithm,
676            }))
677        } else if body_present && !matches(&self.body, &hash.body_hash) {
678            Err(Failure::Fail(Dkim2Error::BodyHashMismatch {
679                m,
680                hash: algorithm,
681            }))
682        } else {
683            Ok(())
684        }
685    }
686}
687
688fn fill(cache: &mut Digests, computed: Digests) {
689    for (slot, digest) in cache.iter_mut().zip(computed) {
690        if digest.is_some() {
691            *slot = digest;
692        }
693    }
694}
695
696fn check_instances(
697    message: &AuthenticatedMessage<'_>,
698    signatures: &[Header<'_, Signature>],
699    instances: &[Header<'_, MessageInstance>],
700    body_present: bool,
701) -> Result<(), Failure> {
702    let mut hashes = StateHashes::default();
703    let mut header_buffer;
704    let mut body_buffer;
705    let mut headers: Cow<'_, [(&[u8], &[u8])]> = Cow::Borrowed(&message.headers);
706    let mut body = body_present.then(|| message.raw_body());
707    let mut from = from_domain(&message.headers);
708    let mut null_body_recipe = None;
709
710    for header in instances.iter().rev() {
711        let instance = &header.header;
712        if let Some(m) = null_body_recipe {
713            return Err(Failure::Fail(Dkim2Error::NullBodyRecipe(m)));
714        }
715        hashes.check(instance, &headers, body)?;
716
717        let Some(recipe) = instance.recipe.as_ref().filter(|_| instance.m > 1) else {
718            continue;
719        };
720        let replaces_from = recipe
721            .headers
722            .iter()
723            .any(|header| header.name.eq_ignore_ascii_case("from"));
724        if replaces_from {
725            let signature = signatures
726                .iter()
727                .map(|h| &h.header)
728                .find(|signature| signature.m >= instance.m)
729                .ok_or(Failure::PermError(Dkim2Error::InstanceNotSigned(
730                    instance.m,
731                )))?;
732            check_from_authentic(from.as_deref(), signature).map_err(Failure::Fail)?;
733        }
734
735        match (&recipe.body, body) {
736            (BodyRecipe::Steps(steps), Some(current)) => {
737                let mut previous = Vec::with_capacity(current.len());
738                apply_body_recipe(&body_lines(current), steps, &mut previous);
739                body_buffer = previous;
740                body = Some(&body_buffer);
741                hashes.body = Default::default();
742            }
743            (BodyRecipe::Unreconstructable, Some(_)) => {
744                null_body_recipe = Some(instance.m);
745                body = None;
746            }
747            (BodyRecipe::Unreconstructable, None) => {}
748            (BodyRecipe::None, _) | (BodyRecipe::Steps(_), None) => {}
749        }
750
751        if !recipe.headers.is_empty() {
752            let mut previous = Vec::with_capacity(headers_len(&headers));
753            recipe.apply_headers(&headers, &mut previous);
754            header_buffer = previous;
755            headers = Cow::Owned(HeaderIterator::new(&header_buffer).collect());
756            hashes.header = Default::default();
757            if replaces_from {
758                from = from_domain(&headers);
759            }
760        }
761    }
762
763    signatures.first().map_or(Ok(()), |first| {
764        check_from_authentic(from.as_deref(), &first.header).map_err(Failure::Fail)
765    })
766}
767
768pub(crate) fn from_domain(headers: &[(&[u8], &[u8])]) -> Option<String> {
769    let mut from = headers
770        .iter()
771        .filter(|(name, _)| name.trim_ascii().eq_ignore_ascii_case(b"from"));
772    let (Some((_, value)), None) = (from.next(), from.next()) else {
773        return None;
774    };
775    let parsed = HeaderForm::Addresses.parse(value);
776    let list = parsed.value().as_address()?;
777    let mut addresses = list.iter();
778    let (Some(Address::Mailbox(mailbox)), None) = (addresses.next(), addresses.next()) else {
779        return None;
780    };
781    let (local, domain) = mailbox.address()?.split_once('@')?;
782    let valid = !local.is_empty()
783        && local.bytes().chain(domain.bytes()).all(|ch| {
784            !ch.is_ascii_whitespace() && !ch.is_ascii_control() && !b"@<>()[],;:\"".contains(&ch)
785        });
786    let domain = to_a_label(domain);
787    (valid && is_domain(domain.as_bytes())).then(|| domain.into_owned())
788}
789
790fn check_from_authentic(domain: Option<&str>, signature: &Signature) -> Result<(), Dkim2Error> {
791    if is_from_authentic(domain, &signature.d) {
792        Ok(())
793    } else {
794        Err(Dkim2Error::FromNotAuthentic(signature.i))
795    }
796}
797
798pub(crate) fn is_from_authentic(from_domain: Option<&str>, signing_domain: &str) -> bool {
799    from_domain.is_some_and(|domain| relaxed_domain_match(domain, signing_domain))
800}
801
802fn local_and_domain(address: &str) -> (&str, &str) {
803    let address = address.strip_prefix('<').unwrap_or(address);
804    let address = address.strip_suffix('>').unwrap_or(address);
805    match address.rsplit_once('@') {
806        Some((local, domain)) => (local, domain),
807        None => (address, ""),
808    }
809}
810
811fn is_null_path(path: &str) -> bool {
812    matches!(path.trim(), "<>" | "")
813}
814
815fn domain_eq(a: &str, b: &str) -> bool {
816    if a.is_ascii() && b.is_ascii() {
817        a.eq_ignore_ascii_case(b)
818    } else {
819        to_a_label(a) == to_a_label(b)
820    }
821}
822
823/// Exact reverse-path or forward-path comparison for the envelope check.
824fn address_matches(envelope: &str, signed: &str) -> bool {
825    let (el, ed) = local_and_domain(envelope);
826    let (sl, sd) = local_and_domain(signed);
827    el == sl && domain_eq(ed, sd)
828}
829
830/// Whether a signed `mf=` or `rt=` value is a well-formed RFC 5321 path.
831#[inline(always)]
832fn is_reverse_path(value: &str) -> bool {
833    value.starts_with('<') && value.ends_with('>')
834}
835
836/// Whether the verifier requires signed `mf=` and `rt=` values to carry
837/// angle brackets.
838#[inline(always)]
839fn require_reverse_path() -> bool {
840    #[cfg(test)]
841    {
842        test_reverse_path::required()
843    }
844    #[cfg(not(test))]
845    {
846        true
847    }
848}
849
850pub(crate) fn relaxed_domain_match(parameter1: &str, parameter2: &str) -> bool {
851    if parameter1.starts_with('[') || parameter2.starts_with('[') {
852        return false;
853    }
854    let (parameter1, parameter2) = if parameter1.is_ascii() && parameter2.is_ascii() {
855        (Cow::Borrowed(parameter1), Cow::Borrowed(parameter2))
856    } else {
857        (to_a_label(parameter1), to_a_label(parameter2))
858    };
859    let mut current = parameter1.as_ref();
860    loop {
861        if current.eq_ignore_ascii_case(&parameter2) {
862            return true;
863        }
864        match current.split_once('.') {
865            Some((_, rest)) if !rest.is_empty() => current = rest,
866            _ => return false,
867        }
868    }
869}
870
871/// Writes the canonicalized `DKIM2-Signature` value with the base64
872/// signature values of the `s=` tag removed (§12.8).
873fn strip_and_canonicalize_signature(signature: &[u8], out: &mut Vec<u8>) {
874    out.extend_from_slice(b"dkim2-signature:");
875    for (pos, spec) in signature.split(|&ch| ch == b';').enumerate() {
876        if pos > 0 {
877            out.push(b';');
878        }
879        let is_signature_tag = spec
880            .split(|&ch| ch == b'=')
881            .next()
882            .is_some_and(|name| name.trim_ascii() == b"s")
883            && spec.contains(&b'=');
884        if !is_signature_tag {
885            out.extend(spec.iter().filter(|ch| !ch.is_ascii_whitespace()));
886            continue;
887        }
888        let mut colons = 0;
889        for &ch in spec.iter().filter(|ch| !ch.is_ascii_whitespace()) {
890            if matches!(ch, b',' | b'|') {
891                colons = 0;
892                out.push(ch);
893            } else if colons < 2 {
894                out.push(ch);
895                colons += usize::from(ch == b':');
896            }
897        }
898    }
899    out.extend_from_slice(b"\r\n");
900}
901
902#[cfg(test)]
903mod canonicalize_test {
904    #[test]
905    fn strip_and_canonicalize_signature() {
906        for (value, expected) in [
907            (
908                "i=1; m=1; t=5; d=ex.com; mf=YQ==; rt=Yg==; s=sel:alg:U0lH;",
909                "dkim2-signature:i=1;m=1;t=5;d=ex.com;mf=YQ==;rt=Yg==;s=sel:alg:;\r\n",
910            ),
911            ("i=1; s=sel:alg:U0lH", "dkim2-signature:i=1;s=sel:alg:\r\n"),
912            ("s=a:b:U0lH,c:d:WkZa;", "dkim2-signature:s=a:b:,c:d:;\r\n"),
913            (
914                "s=a:b:U0lH|c:d:WkZa, e:f:QUJD;",
915                "dkim2-signature:s=a:b:|c:d:,e:f:;\r\n",
916            ),
917            (
918                "s=sel:alg:U0lH; f=feedback;",
919                "dkim2-signature:s=sel:alg:;f=feedback;\r\n",
920            ),
921            (
922                "i=1;\r\n m=1;\r\n\ts=sel:alg:U0\r\n lH;",
923                "dkim2-signature:i=1;m=1;s=sel:alg:;\r\n",
924            ),
925            ("  i=1; s=a:b:CC;  ", "dkim2-signature:i=1;s=a:b:;\r\n"),
926            ("s=a:b:CC; i=1;", "dkim2-signature:s=a:b:;i=1;\r\n"),
927            (
928                "i=1;s=a:b:CC;f=exploded;",
929                "dkim2-signature:i=1;s=a:b:;f=exploded;\r\n",
930            ),
931            ("n=foo; s=a:b:CC;", "dkim2-signature:n=foo;s=a:b:;\r\n"),
932            (
933                "d=sub.ex.com; s=ed25519:ed25519-sha256:F//Dt+leS4H;",
934                "dkim2-signature:d=sub.ex.com;s=ed25519:ed25519-sha256:;\r\n",
935            ),
936            ("", "dkim2-signature:\r\n"),
937            ("d=as; s=a:b:CC;", "dkim2-signature:d=as;s=a:b:;\r\n"),
938            (
939                "s=badset; n=a:b:c;",
940                "dkim2-signature:s=badset;n=a:b:c;\r\n",
941            ),
942            ("s=; n=a:b:c;", "dkim2-signature:s=;n=a:b:c;\r\n"),
943            ("s=sel:alg; i=1;", "dkim2-signature:s=sel:alg;i=1;\r\n"),
944            (
945                "i=1; mf=QQ s=; s=a:b:CC;",
946                "dkim2-signature:i=1;mf=QQs=;s=a:b:;\r\n",
947            ),
948            ("mf=QQ s=; n=a:b:c;", "dkim2-signature:mf=QQs=;n=a:b:c;\r\n"),
949            (
950                "rt=QQ s=,WWW; s=a:b:CC;",
951                "dkim2-signature:rt=QQs=,WWW;s=a:b:;\r\n",
952            ),
953            ("s=se l:al g:CC;", "dkim2-signature:s=sel:alg:;\r\n"),
954            ("n= s=a:b:c;", "dkim2-signature:n=s=a:b:c;\r\n"),
955            (
956                "x=1 s=a:b:c; s=a:b:c;",
957                "dkim2-signature:x=1s=a:b:c;s=a:b:;\r\n",
958            ),
959            ("\r\n\ts\r\n =a:b:c", "dkim2-signature:s=a:b:\r\n"),
960        ] {
961            let mut out = Vec::new();
962            super::strip_and_canonicalize_signature(value.as_bytes(), &mut out);
963            assert_eq!(
964                String::from_utf8(out).unwrap(),
965                expected,
966                "input: {value:?}"
967            );
968        }
969    }
970}
971
972#[cfg(test)]
973pub(crate) mod test_reverse_path {
974    use std::cell::Cell;
975
976    thread_local! {
977        static REQUIRED: Cell<bool> = const { Cell::new(true) };
978    }
979
980    pub(super) fn required() -> bool {
981        REQUIRED.with(Cell::get)
982    }
983
984    /// Scope guard that relaxes the reverse-path requirement on the current
985    /// thread, restoring it on drop.
986    pub(crate) struct LenientReversePath;
987
988    impl LenientReversePath {
989        pub(crate) fn new() -> Self {
990            REQUIRED.with(|r| r.set(false));
991            LenientReversePath
992        }
993    }
994
995    impl Drop for LenientReversePath {
996        fn drop(&mut self) {
997            REQUIRED.with(|r| r.set(true));
998        }
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests;