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