Skip to main content

mail_auth/dkim2/
sign.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 signing (§12): the [`Dkim2Signer`] signing methods, the [`Hop`] and
8//! [`Envelope`] a signature binds to, and the resulting [`Dkim2Signed`].
9
10use super::{
11    ChainBinding, Dkim2Error, Dkim2Hash, Dkim2Output, Dkim2Signer, Flag, MessageHash,
12    MessageInstance, Signature, SignatureValue,
13    parse::{MAX_SIGNATURE_VALUES, is_domain, is_textstring},
14    recipe::Recipe,
15    verify::{StateHashes, check_custody, check_mail_from_domain, from_domain, is_from_authentic},
16};
17use crate::SystemTime;
18use crate::dkim2::BodyRecipe;
19use crate::{
20    AuthenticatedMessage, Error,
21    crypto::SigningKey,
22    dkim2::canonicalize::{CanonicalizedHeaderWriter, Digests, body_digests, header_digests},
23    dns::to_a_label,
24    headers::{Header, Writable, Writer},
25    signer::Ready,
26};
27
28/// SMTP envelope of a message transaction.
29///
30/// Passed to [`MessageAuthenticator::verify_dkim2`](crate::MessageAuthenticator::verify_dkim2)
31/// and [`MessageAuthenticator::verify_dkim2_dsn`](crate::MessageAuthenticator::verify_dkim2_dsn)
32/// as the envelope the message was received with, and wrapped by
33/// [`Hop::Real`] as the envelope a signed message is sent with.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Envelope<A, R>
36where
37    A: AsRef<str>,
38    R: IntoIterator<Item: AsRef<str>>,
39{
40    /// `MAIL FROM` address, with or without angle brackets; `<>` for a null
41    /// reverse-path.
42    pub mail_from: A,
43    /// `RCPT TO` addresses, with or without angle brackets.
44    pub rcpt_to: R,
45}
46
47impl<A, R> Envelope<A, R>
48where
49    A: AsRef<str>,
50    R: IntoIterator<Item: AsRef<str>>,
51{
52    /// Creates an envelope from the `MAIL FROM` address and the `RCPT TO`
53    /// addresses.
54    pub fn new(mail_from: A, rcpt_to: R) -> Self {
55        Envelope { mail_from, rcpt_to }
56    }
57}
58
59/// Next hop of a signed message, which a signature binds to for the chain
60/// of custody (§12.2 and §12.3).
61///
62/// Build it with [`Hop::real`] or [`Hop::imaginary`], and mark a real hop
63/// that is one of several copies with [`Hop::exploded`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Hop<A, R, I>
66where
67    A: AsRef<str>,
68    R: IntoIterator<Item: AsRef<str>>,
69    I: Into<String>,
70{
71    /// The message is sent over SMTP with this envelope. The signature
72    /// records it in the `mf=` and `rt=` tags.
73    Real(Envelope<A, R>),
74    /// As [`Hop::Real`], for one of several copies of a message that an
75    /// intermediary sends to more than one address (§12.5). The signature
76    /// gets the `exploded` flag even when this envelope has a single `RCPT
77    /// TO`, except on the first signature (`i=1`), where the flag is not
78    /// allowed and is left out.
79    Exploded(Envelope<A, R>),
80    /// The message moves to another domain without an SMTP transaction. The
81    /// signature records the next domain in the `nd=` tag.
82    Imaginary {
83        /// Domain (`d=`) that the next signature must use.
84        next_domain: I,
85    },
86}
87
88impl<A, R> Hop<A, R, String>
89where
90    A: AsRef<str>,
91    R: IntoIterator<Item: AsRef<str>>,
92{
93    /// Creates a hop for an SMTP transaction with this `MAIL FROM` and
94    /// these `RCPT TO` addresses.
95    ///
96    /// Addresses without angle brackets are wrapped in them when signed, and
97    /// U-label domains are recorded as A-labels (§18). Use `"<>"` as
98    /// `mail_from` for a null reverse-path, as in a DSN.
99    pub fn real(mail_from: A, rcpt_to: R) -> Self {
100        Hop::Real(Envelope::new(mail_from, rcpt_to))
101    }
102}
103
104impl<A, R, I> Hop<A, R, I>
105where
106    A: AsRef<str>,
107    R: IntoIterator<Item: AsRef<str>>,
108    I: Into<String>,
109{
110    /// Marks a real hop as one of several copies of an exploded message
111    /// (§12.5), turning [`Hop::Real`] into [`Hop::Exploded`]. Other hops are
112    /// returned unchanged.
113    pub fn exploded(self) -> Self {
114        match self {
115            Hop::Real(envelope) => Hop::Exploded(envelope),
116            hop => hop,
117        }
118    }
119}
120
121impl<I> Hop<&'static str, [&'static str; 0], I>
122where
123    I: Into<String>,
124{
125    /// Creates an imaginary hop that hands the message to `next_domain`
126    /// without an SMTP transaction, for example between two domains hosted
127    /// on the same system (§12.3).
128    ///
129    /// The next signature must be made by `next_domain`, and a chain cannot
130    /// end with an imaginary hop: the verifier rejects a most recent
131    /// signature that carries `nd=`. The message is then not a Fully
132    /// Verified DKIM2 Message, so the signer adding the next signature needs
133    /// [`Dkim2Signer::out_of_band_arrangement`].
134    pub fn imaginary(next_domain: I) -> Self {
135        Hop::Imaginary { next_domain }
136    }
137}
138
139/// Header fields produced by a [`Dkim2Signer`] signing method.
140///
141/// Prepend them to the signed message with [`write`](Self::write) or
142/// [`to_header`](Self::to_header).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Dkim2Signed {
145    /// New `Message-Instance` header field, or `None` when the message
146    /// already has one that describes its current content, or when the
147    /// caller supplies the instance
148    /// ([`sign_with_message_instance`](Dkim2Signer::sign_with_message_instance)).
149    pub message_instance: Option<MessageInstance>,
150    /// New `DKIM2-Signature` header field.
151    pub signature: Signature,
152}
153
154impl Dkim2Signed {
155    /// Writes the `DKIM2-Signature` header field and then, when present, the
156    /// `Message-Instance` header field, each unfolded and terminated by
157    /// CRLF.
158    pub fn write(&self, writer: &mut impl Writer) {
159        self.signature.write(writer);
160        if let Some(instance) = &self.message_instance {
161            instance.write(writer);
162        }
163    }
164
165    /// Returns the header fields written by [`write`](Self::write) as a
166    /// string.
167    pub fn to_header(&self) -> String {
168        let mut buf = Vec::new();
169        self.write(&mut buf);
170        String::from_utf8(buf).unwrap_or_default()
171    }
172}
173
174impl Dkim2Signer<Ready> {
175    /// Signs a message whose content has not changed, as an Originator or a
176    /// Forwarder that does not modify the message.
177    ///
178    /// `message` is the raw message, including any DKIM2 header fields added
179    /// by earlier hops. `hop` is the next hop the signature binds to.
180    /// `verified` is the [`Dkim2Output`] of a full verification of `message`
181    /// that passed (§12): required when the message already has DKIM2 header
182    /// fields, unless the signer has an
183    /// [`out_of_band_arrangement`](Self::out_of_band_arrangement); pass
184    /// `None` for a new message. The result always holds a new
185    /// `DKIM2-Signature`, and holds a new `Message-Instance` (`m=1`) only if
186    /// the message has none yet.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`Error::Parse`] if the message cannot be parsed,
191    /// [`Dkim2Error::NotFullyVerified`] if the validity proof is missing or
192    /// does not match the message, [`Dkim2Error::FromNotAuthentic`] if the
193    /// message is new (or its `From` changed) and its `From` domain is not
194    /// the signing domain or a subdomain of it (§12.4), [`Dkim2Error::HeaderHashMismatch`] or
195    /// [`Dkim2Error::BodyHashMismatch`] if the message changed since its most
196    /// recent `Message-Instance` (use [`sign_revised`](Self::sign_revised)),
197    /// [`Dkim2Error::SignatureSyntax`] if the signing domain, the `nd=`
198    /// domain or a selector is not an RFC 5321 domain (U-labels are mapped to
199    /// A-labels first), [`Dkim2Error::SignatureTagMissing`] for a [`Hop`]
200    /// without `RCPT TO` addresses, [`Dkim2Error::SignatureSyntax`] for an
201    /// empty or null `RCPT TO` address, a chain of custody, `mf=`/`d=`, selector,
202    /// nonce or `exploded` flag error for a signature that would not be valid
203    /// (§12.2 to §12.5),
204    /// [`Dkim2Error::SequenceOverflow`] if the next `i=` would exceed
205    /// `u32::MAX`, or the signing key's error if signing fails.
206    pub fn sign<'x, M, A, R, I>(
207        &self,
208        message: M,
209        hop: Hop<A, R, I>,
210        verified: Option<&Dkim2Output<'_>>,
211    ) -> crate::Result<Dkim2Signed>
212    where
213        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
214        A: AsRef<str>,
215        R: IntoIterator<Item: AsRef<str>>,
216        I: Into<String>,
217    {
218        self.sign_at(message, hop, verified, now())
219    }
220
221    /// Signs a message whose content changed, as a Reviser.
222    ///
223    /// `original` is the message as received and `verified` the passing full
224    /// verification of it (see [`sign`](Self::sign)). `modified` is the
225    /// message to be sent, which keeps the DKIM2 header fields of
226    /// `original`. The recipe that recreates `original` from `modified` is
227    /// computed with [`Recipe::diff`]. If the signed content changed, the
228    /// result holds a new `Message-Instance` with the next `m=`, the new
229    /// hashes and the recipe; otherwise it holds only the `DKIM2-Signature`,
230    /// as with [`sign`](Self::sign).
231    ///
232    /// # Errors
233    ///
234    /// Same as [`sign`](Self::sign); either message can fail to parse.
235    ///
236    /// # Example
237    ///
238    /// ```rust,no_run
239    /// use mail_auth::{
240    ///     AuthenticatedMessage, MessageAuthenticator,
241    ///     crypto::Ed25519Key,
242    ///     dkim2::{Dkim2Signer, Envelope, Hop},
243    /// };
244    ///
245    /// # async fn run() -> mail_auth::Result<()> {
246    /// # let pkcs8: &[u8] = &[];
247    /// # let received: &[u8] = &[];
248    /// # let modified: &[u8] = &[];
249    /// let authenticator = MessageAuthenticator::new_cloudflare_tls().unwrap();
250    /// let message = AuthenticatedMessage::parse(received).unwrap();
251    /// let verified = authenticator
252    ///     .verify_dkim2(&message, Envelope::new("sender@example.com", ["list@example.org"]))
253    ///     .await;
254    ///
255    /// let signed = Dkim2Signer::from_key(Ed25519Key::from_pkcs8_der(pkcs8)?)
256    ///     .domain("example.org")
257    ///     .selector("ed25519")
258    ///     .sign_revised(
259    ///         received,
260    ///         modified,
261    ///         Hop::real("list-bounces@example.org", ["member@example.net"]),
262    ///         Some(&verified),
263    ///     )?;
264    ///
265    /// let mut outgoing = Vec::new();
266    /// signed.write(&mut outgoing);
267    /// outgoing.extend_from_slice(modified);
268    /// # Ok(())
269    /// # }
270    /// ```
271    pub fn sign_revised<'x, O, M, A, R, I>(
272        &self,
273        original: O,
274        modified: M,
275        hop: Hop<A, R, I>,
276        verified: Option<&Dkim2Output<'_>>,
277    ) -> crate::Result<Dkim2Signed>
278    where
279        O: TryInto<AuthenticatedMessage<'x>, Error = Error>,
280        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
281        A: AsRef<str>,
282        R: IntoIterator<Item: AsRef<str>>,
283        I: Into<String>,
284    {
285        self.sign_revised_at(original, modified, hop, verified, now())
286    }
287
288    pub(crate) fn sign_revised_at<'x, O, M, A, R, I>(
289        &self,
290        original: O,
291        modified: M,
292        hop: Hop<A, R, I>,
293        verified: Option<&Dkim2Output<'_>>,
294        now: u64,
295    ) -> crate::Result<Dkim2Signed>
296    where
297        O: TryInto<AuthenticatedMessage<'x>, Error = Error>,
298        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
299        A: AsRef<str>,
300        R: IntoIterator<Item: AsRef<str>>,
301        I: Into<String>,
302    {
303        let modified = modified.try_into()?;
304        let new_instance =
305            MessageInstance::from_message(&modified, Some(&original.try_into()?), &self.hashes)?;
306
307        self.sign_internal(&modified, hop, new_instance.as_ref(), verified, now)
308            .map(|signature| Dkim2Signed {
309                message_instance: new_instance,
310                signature,
311            })
312    }
313
314    /// Signs a changed message with a caller-supplied recipe.
315    ///
316    /// `recipe` recreates the previous revision from `modified`, which keeps
317    /// the DKIM2 header fields of the received message; `verified` is the
318    /// passing full verification of the received message (see
319    /// [`sign`](Self::sign)). The result holds a new `Message-Instance` with
320    /// the next `m=`, the hashes of `modified` and `recipe`.
321    ///
322    /// # Errors
323    ///
324    /// Same as [`sign`](Self::sign), and [`Dkim2Error::RecipeSyntax`] if
325    /// `recipe` fails [`Recipe::validate`].
326    pub fn sign_with_recipe<'x, M, A, R, I>(
327        &self,
328        modified: M,
329        recipe: Recipe,
330        hop: Hop<A, R, I>,
331        verified: Option<&Dkim2Output<'_>>,
332    ) -> crate::Result<Dkim2Signed>
333    where
334        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
335        A: AsRef<str>,
336        R: IntoIterator<Item: AsRef<str>>,
337        I: Into<String>,
338    {
339        self.sign_with_recipe_at(modified, recipe, hop, verified, now())
340    }
341
342    /// Signs a message with a caller-supplied `Message-Instance`.
343    ///
344    /// `instance` is signed as the newest `Message-Instance` of `modified`;
345    /// with `None`, only the instances already present are signed. The
346    /// returned [`Dkim2Signed::message_instance`] is always `None`: the caller
347    /// writes `instance` to the message itself, next to the returned
348    /// `DKIM2-Signature`. `verified` is as in [`sign`](Self::sign).
349    ///
350    /// # Errors
351    ///
352    /// Same as [`sign`](Self::sign), except that parsing cannot fail;
353    /// [`Dkim2Error::InstanceSyntax`] if `instance` is not numbered one more
354    /// than the highest existing `m=`, [`Dkim2Error::HeaderHashMismatch`] or
355    /// [`Dkim2Error::BodyHashMismatch`] if its hashes do not match
356    /// `modified`, and [`Dkim2Error::RecipeSyntax`] if its recipe fails
357    /// [`Recipe::validate`].
358    pub fn sign_with_message_instance<'x, A, R, I>(
359        &self,
360        modified: &AuthenticatedMessage<'x>,
361        instance: Option<&MessageInstance>,
362        hop: Hop<A, R, I>,
363        verified: Option<&Dkim2Output<'_>>,
364    ) -> crate::Result<Dkim2Signed>
365    where
366        A: AsRef<str>,
367        R: IntoIterator<Item: AsRef<str>>,
368        I: Into<String>,
369    {
370        if let Some(instance) = instance {
371            let expected = modified
372                .dkim2_instances
373                .last()
374                .map_or(0, |h| h.header.m)
375                .checked_add(1)
376                .ok_or(Error::Dkim2(Dkim2Error::SequenceOverflow))?;
377            if instance.m != expected {
378                return Err(Error::Dkim2(Dkim2Error::InstanceSyntax(instance.m)));
379            }
380            StateHashes::default()
381                .check(instance, &modified.headers, Some(modified.raw_body()))
382                .map_err(|failure| Error::Dkim2(failure.into_error()))?;
383        }
384        self.sign_internal(modified, hop, instance, verified, now())
385            .map(|signature| Dkim2Signed {
386                message_instance: None,
387                signature,
388            })
389    }
390
391    pub(crate) fn sign_at<'x, M, A, R, I>(
392        &self,
393        message: M,
394        hop: Hop<A, R, I>,
395        verified: Option<&Dkim2Output<'_>>,
396        now: u64,
397    ) -> crate::Result<Dkim2Signed>
398    where
399        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
400        A: AsRef<str>,
401        R: IntoIterator<Item: AsRef<str>>,
402        I: Into<String>,
403    {
404        let message = message.try_into()?;
405        let new_instance = MessageInstance::from_message(&message, None, &self.hashes)?;
406
407        self.sign_internal(&message, hop, new_instance.as_ref(), verified, now)
408            .map(|signature| Dkim2Signed {
409                message_instance: new_instance,
410                signature,
411            })
412    }
413
414    pub(crate) fn sign_with_recipe_at<'x, M, A, R, I>(
415        &self,
416        modified: M,
417        recipe: Recipe,
418        hop: Hop<A, R, I>,
419        verified: Option<&Dkim2Output<'_>>,
420        now: u64,
421    ) -> crate::Result<Dkim2Signed>
422    where
423        M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
424        A: AsRef<str>,
425        R: IntoIterator<Item: AsRef<str>>,
426        I: Into<String>,
427    {
428        recipe.validate()?;
429        let message = modified.try_into()?;
430        let new_instance = MessageInstance::from_recipe(&message, Some(recipe), &self.hashes);
431
432        self.sign_internal(&message, hop, new_instance.as_ref(), verified, now)
433            .map(|signature| Dkim2Signed {
434                message_instance: new_instance,
435                signature,
436            })
437    }
438
439    fn sign_internal<'x, A, R, I>(
440        &self,
441        parsed: &AuthenticatedMessage<'x>,
442        hop: Hop<A, R, I>,
443        new_instance: Option<&MessageInstance>,
444        verified: Option<&Dkim2Output<'_>>,
445        now: u64,
446    ) -> crate::Result<Signature>
447    where
448        A: AsRef<str>,
449        R: IntoIterator<Item: AsRef<str>>,
450        I: Into<String>,
451    {
452        let instances = parsed.dkim2_instances.as_slice();
453        let signatures = parsed.dkim2_signatures.as_slice();
454
455        if (!signatures.is_empty() || !instances.is_empty() || parsed.has_dkim2_errors)
456            && !self.out_of_band
457            && !verified.is_some_and(|output| output.is_proof_for(parsed))
458        {
459            return Err(Error::Dkim2(Dkim2Error::NotFullyVerified));
460        }
461
462        let highest_m = instances.last().map(|h| h.header.m).unwrap_or(0);
463        let highest_i = signatures.last().map(|h| h.header.i).unwrap_or(0);
464
465        let new_m = new_instance.as_ref().map(|mi| mi.m).unwrap_or(highest_m);
466        let next_i = highest_i
467            .checked_add(1)
468            .ok_or(Error::Dkim2(Dkim2Error::SequenceOverflow))?;
469
470        let domain = signing_domain(&self.domain, next_i)?;
471        if self
472            .keys
473            .iter()
474            .any(|entry| !is_domain(entry.selector.as_bytes()))
475        {
476            return Err(Error::Dkim2(Dkim2Error::SignatureSyntax(next_i)));
477        }
478        if let Some(recipe) = new_instance.and_then(|instance| instance.recipe.as_ref()) {
479            recipe.validate()?;
480        }
481        let from_changed = new_instance
482            .and_then(|instance| instance.recipe.as_ref())
483            .is_some_and(|recipe| {
484                recipe
485                    .headers
486                    .iter()
487                    .any(|header| header.name.eq_ignore_ascii_case("from"))
488            });
489        if (signatures.is_empty() || from_changed)
490            && require_authentic_from()
491            && !is_from_authentic(from_domain(&parsed.headers).as_deref(), &domain)
492        {
493            return Err(Error::Dkim2(Dkim2Error::FromNotAuthentic(next_i)));
494        }
495
496        if new_instance.is_none()
497            && let Some(highest) = instances.last()
498            && !verified.is_some_and(|output| output.covers(parsed.raw_message))
499        {
500            StateHashes::default()
501                .check(&highest.header, &parsed.headers, Some(parsed.raw_body()))
502                .map_err(|failure| Error::Dkim2(failure.into_error()))?;
503        }
504
505        let mut flags = Vec::with_capacity(self.flags.len() + 1);
506        for flag in &self.flags {
507            let flag = match flag {
508                Flag::Unknown(name) if is_textstring(name.as_bytes()) => Flag::parse(name),
509                Flag::Unknown(_) => {
510                    return Err(Error::Dkim2(Dkim2Error::SignatureSyntax(next_i)));
511                }
512                known => known.clone(),
513            };
514            if !flags.contains(&flag) {
515                flags.push(flag);
516            }
517        }
518        let (hop, exploded) = match hop {
519            Hop::Exploded(envelope) => (Hop::Real(envelope), true),
520            hop => (hop, false),
521        };
522        let chain = match hop {
523            Hop::Real(envelope) | Hop::Exploded(envelope) => {
524                let rcpt_to = envelope
525                    .rcpt_to
526                    .into_iter()
527                    .map(|rcpt| to_reverse_path(rcpt.as_ref()))
528                    .collect::<Vec<_>>();
529                if rcpt_to.is_empty() {
530                    return Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
531                        i: next_i,
532                        tag: "rt",
533                    }));
534                } else if !rcpt_to.iter().all(|rcpt| is_forward_path(rcpt)) {
535                    return Err(Error::Dkim2(Dkim2Error::SignatureSyntax(next_i)));
536                }
537                if next_i > 1 && (exploded || rcpt_to.len() > 1) && !flags.contains(&Flag::Exploded)
538                {
539                    flags.push(Flag::Exploded);
540                }
541                ChainBinding::Envelope {
542                    mail_from: to_reverse_path(envelope.mail_from.as_ref()),
543                    rcpt_to,
544                }
545            }
546            Hop::Imaginary { next_domain } => {
547                ChainBinding::NextDomain(signing_domain(&next_domain.into(), next_i)?)
548            }
549        };
550
551        let mut signature = Signature {
552            i: next_i,
553            m: new_m,
554            t: now,
555            d: domain,
556            s: self
557                .keys
558                .iter()
559                .map(|entry| SignatureValue {
560                    selector: entry.selector.clone(),
561                    a: entry.key.algorithm().into(),
562                    b: Vec::new(),
563                    group: entry.group,
564                })
565                .collect(),
566            chain,
567            n: self.nonce.clone(),
568            flags,
569        };
570        self.check_signature(&signature, signatures.last().map(|h| &h.header))
571            .map_err(Error::Dkim2)?;
572
573        let mut input = Vec::with_capacity(256 * (instances.len() + signatures.len() + 2));
574        SignatureInput {
575            instances,
576            signatures,
577            new_signature: &signature,
578            new_instance,
579        }
580        .write(&mut input);
581
582        for (value, entry) in signature.s.iter_mut().zip(&self.keys) {
583            value.b = entry.key.sign(input.as_slice())?;
584        }
585
586        Ok(signature)
587    }
588
589    fn check_signature(
590        &self,
591        signature: &Signature,
592        previous: Option<&Signature>,
593    ) -> Result<(), Dkim2Error> {
594        if signature.i == 1 && signature.flags.contains(&Flag::Exploded) {
595            return Err(Dkim2Error::SignatureTagUnexpected {
596                i: signature.i,
597                tag: "f",
598            });
599        } else if signature.n.as_ref().is_some_and(|nonce| {
600            nonce.len() > MAX_NONCE_LEN
601                || !nonce.bytes().all(|ch| ch.is_ascii_graphic() && ch != b';')
602        }) {
603            return Err(Dkim2Error::SignatureSyntax(signature.i));
604        }
605
606        if signature.s.len() > MAX_SIGNATURE_VALUES {
607            return Err(Dkim2Error::TooManySelectors(signature.i));
608        }
609        for (pos, value) in signature.s.iter().enumerate() {
610            if signature
611                .s
612                .iter()
613                .skip(pos + 1)
614                .any(|other| value.selector.eq_ignore_ascii_case(&other.selector))
615            {
616                return Err(Dkim2Error::DuplicateSelector(signature.i));
617            }
618        }
619
620        check_mail_from_domain(signature)?;
621        previous.map_or(Ok(()), |previous| check_custody(previous, signature))
622    }
623}
624
625#[inline(always)]
626fn require_authentic_from() -> bool {
627    #[cfg(test)]
628    {
629        test_authentic_from::required()
630    }
631    #[cfg(not(test))]
632    {
633        true
634    }
635}
636
637#[cfg(test)]
638pub(crate) mod test_authentic_from {
639    use std::cell::Cell;
640
641    thread_local! {
642        static REQUIRED: Cell<bool> = const { Cell::new(true) };
643    }
644
645    pub(super) fn required() -> bool {
646        REQUIRED.with(Cell::get)
647    }
648
649    pub(crate) struct LenientFrom;
650
651    impl LenientFrom {
652        pub(crate) fn new() -> Self {
653            REQUIRED.with(|r| r.set(false));
654            LenientFrom
655        }
656    }
657
658    impl Drop for LenientFrom {
659        fn drop(&mut self) {
660            REQUIRED.with(|r| r.set(true));
661        }
662    }
663}
664
665fn is_forward_path(path: &str) -> bool {
666    path.strip_prefix('<')
667        .and_then(|inner| inner.strip_suffix('>'))
668        .filter(|inner| {
669            !inner.contains(|ch: char| ch.is_ascii_whitespace() || matches!(ch, '<' | '>'))
670        })
671        .and_then(|inner| inner.rsplit_once('@'))
672        .is_some_and(|(local, domain)| !local.is_empty() && !domain.is_empty())
673}
674
675fn signing_domain(domain: &str, i: u32) -> crate::Result<String> {
676    let domain = to_a_label(domain.trim());
677    if is_domain(domain.as_bytes()) {
678        Ok(domain.into_owned())
679    } else {
680        Err(Error::Dkim2(Dkim2Error::SignatureSyntax(i)))
681    }
682}
683
684const MAX_NONCE_LEN: usize = 64;
685
686impl MessageInstance {
687    /// Builds the `Message-Instance` a signer adds to `message`, if one is
688    /// needed (§12.1).
689    ///
690    /// Without `original`, returns an instance with `m=1` if `message` has no
691    /// `Message-Instance` yet, and `None` otherwise. With `original` (the
692    /// message before modification), computes [`Recipe::diff`] and returns
693    /// a new instance carrying the recipe when the signed content changed,
694    /// or when `message` has no instance yet. The instance number is one
695    /// more than the highest existing `m=` and the hashes use `hashes`.
696    ///
697    /// # Errors
698    ///
699    /// Returns [`Dkim2Error::HeaderNotRepresentable`] when [`Recipe::diff`]
700    /// cannot record a changed header field.
701    pub fn from_message(
702        message: &AuthenticatedMessage<'_>,
703        original: Option<&AuthenticatedMessage<'_>>,
704        hashes: &[Dkim2Hash],
705    ) -> crate::Result<Option<Self>> {
706        let recipe = original
707            .map(|original| Recipe::diff(original, message))
708            .transpose()?
709            .filter(|r| !r.headers.is_empty() || r.body != BodyRecipe::None);
710        Ok(Self::from_recipe(message, recipe, hashes))
711    }
712
713    /// Builds a `Message-Instance` for `message` carrying `recipe`.
714    ///
715    /// Returns `None` only when `recipe` is `None` and `message` already has
716    /// a `Message-Instance`. The instance number is one more than the highest
717    /// existing `m=` and the hashes of `message` use `hashes`.
718    pub fn from_recipe(
719        message: &AuthenticatedMessage<'_>,
720        recipe: Option<Recipe>,
721        hashes: &[Dkim2Hash],
722    ) -> Option<Self> {
723        let instances = message.dkim2_instances.as_slice();
724
725        if !instances.is_empty() && recipe.is_none() {
726            return None;
727        }
728        let mut wanted = [false; Dkim2Hash::COUNT];
729        for hash in hashes {
730            if let Some(slot) = wanted.get_mut(hash.index()) {
731                *slot = true;
732            }
733        }
734        let header_hashes = header_digests(message.headers.iter().copied(), wanted);
735        let body_hashes = body_digests(message.raw_body(), wanted);
736        let digest = |digests: &Digests, hash: Dkim2Hash| {
737            digests
738                .get(hash.index())
739                .and_then(Option::as_ref)
740                .map(|digest| digest.as_ref().to_vec())
741                .unwrap_or_default()
742        };
743
744        Some(MessageInstance {
745            m: instances
746                .last()
747                .map(|h| h.header.m)
748                .unwrap_or(0)
749                .saturating_add(1),
750            hashes: hashes
751                .iter()
752                .map(|hash| MessageHash {
753                    name: (*hash).into(),
754                    header_hash: digest(&header_hashes, *hash),
755                    body_hash: digest(&body_hashes, *hash),
756                    group: 0,
757                })
758                .collect(),
759            recipe,
760        })
761    }
762}
763
764struct SignatureInput<'x> {
765    instances: &'x [Header<'x, MessageInstance>],
766    signatures: &'x [Header<'x, Signature>],
767    new_signature: &'x Signature,
768    new_instance: Option<&'x MessageInstance>,
769}
770
771impl<'x> Writable for SignatureInput<'x> {
772    fn write(self, writer: &mut impl Writer) {
773        for header in self.instances {
774            let mut w = CanonicalizedHeaderWriter::new(writer, header.name);
775            w.write(header.value);
776            w.finalize();
777        }
778
779        if let Some(instance) = self.new_instance {
780            let mut w = CanonicalizedHeaderWriter::new(writer, b"Message-Instance");
781            instance.write_value(&mut w);
782            w.finalize();
783        }
784
785        for header in self.signatures {
786            let mut w = CanonicalizedHeaderWriter::new(writer, header.name);
787            w.write(header.value);
788            w.finalize();
789        }
790
791        let mut w = CanonicalizedHeaderWriter::new(writer, b"DKIM2-Signature");
792        self.new_signature.write_value(&mut w, true);
793        w.finalize();
794    }
795}
796
797pub(crate) fn now() -> u64 {
798    SystemTime::now()
799        .duration_since(SystemTime::UNIX_EPOCH)
800        .map(|d| d.as_secs())
801        .unwrap_or(0)
802}
803
804/// Wraps an address in angle brackets to form an RFC 5321 path, unless it
805/// already has them, and maps a U-label domain to A-labels (§18).
806pub(super) fn to_reverse_path(addr: &str) -> String {
807    let addr = addr.trim();
808    let path = addr
809        .strip_prefix('<')
810        .and_then(|addr| addr.strip_suffix('>'))
811        .unwrap_or(addr);
812    match path.rsplit_once('@') {
813        Some((local, domain)) if !domain.is_ascii() => {
814            format!("<{local}@{}>", to_a_label(domain))
815        }
816        _ => format!("<{path}>"),
817    }
818}
819
820#[cfg(test)]
821mod test {
822    use super::{Dkim2Signer, Hop};
823    use crate::{
824        crypto::{Algorithm, DkimKey, Ed25519Key, MlDsa44Key, RsaKey, Sha256},
825        dkim2::{Dkim2Hash, Dkim2Signed, MessageInstance},
826    };
827    use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
828    use std::{borrow::Cow, path::PathBuf};
829
830    const TIMESTAMP: u64 = 1740000000;
831
832    fn normalize_crlf(message: &[u8]) -> Cow<'_, [u8]> {
833        let mut needs_fix = false;
834        let mut iter = message.iter().peekable();
835        while let Some(&ch) = iter.next() {
836            match ch {
837                b'\r' => {
838                    if iter.peek() != Some(&&b'\n') {
839                        needs_fix = true;
840                        break;
841                    }
842                    iter.next();
843                }
844                b'\n' => {
845                    needs_fix = true;
846                    break;
847                }
848                _ => {}
849            }
850        }
851
852        if !needs_fix {
853            return Cow::Borrowed(message);
854        }
855
856        let mut out = Vec::with_capacity(message.len() + 16);
857        for ch in message {
858            match ch {
859                b'\r' => {}
860                b'\n' => out.extend_from_slice(b"\r\n"),
861                _ => out.push(*ch),
862            }
863        }
864        Cow::Owned(out)
865    }
866
867    fn resource(parts: &[&str]) -> PathBuf {
868        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
869        path.push("resources/dkim2");
870        for part in parts {
871            path.push(part);
872        }
873        path
874    }
875
876    #[test]
877    fn sign_with_message_instance_leaves_instance_to_caller() {
878        let raw = normalize_crlf(&std::fs::read(resource(&["emails", "simple.eml"])).unwrap())
879            .into_owned();
880        let message = crate::AuthenticatedMessage::parse(&raw).unwrap();
881        let instance = MessageInstance::from_message(&message, None, &[Dkim2Hash::Sha256]).unwrap();
882        assert_eq!(instance.as_ref().map(|instance| instance.m), Some(1));
883
884        let signed = Dkim2Signer::from_key(load_ed25519("test1.dkim2.com", "ed25519"))
885            .domain("test1.dkim2.com")
886            .selector("ed25519")
887            .sign_with_message_instance(
888                &message,
889                instance.as_ref(),
890                Hop::real("sender@test1.dkim2.com", ["recipient@example.com"]),
891                None,
892            )
893            .unwrap();
894        assert!(signed.message_instance.is_none());
895        assert_eq!((signed.signature.i, signed.signature.m), (1, 1));
896    }
897
898    // A caller-supplied instance must be the next m= and describe the message; recipes are validated.
899    #[test]
900    fn sign_with_message_instance_checks_instance() {
901        use crate::dkim2::{BodyRecipe, Dkim2Error, HeaderRecipe, Recipe, Step};
902
903        let raw = normalize_crlf(&std::fs::read(resource(&["emails", "simple.eml"])).unwrap())
904            .into_owned();
905        let message = crate::AuthenticatedMessage::parse(&raw).unwrap();
906        let signer = Dkim2Signer::from_key(load_ed25519("test1.dkim2.com", "ed25519"))
907            .domain("test1.dkim2.com")
908            .selector("ed25519");
909        let hop = || Hop::real("sender@test1.dkim2.com", ["recipient@example.com"]);
910        let instance = MessageInstance::from_message(&message, None, &[Dkim2Hash::Sha256])
911            .unwrap()
912            .unwrap();
913        let err = |instance: &MessageInstance| match signer
914            .sign_with_message_instance(&message, Some(instance), hop(), None)
915            .unwrap_err()
916        {
917            crate::Error::Dkim2(error) => error,
918            other => panic!("unexpected {other:?}"),
919        };
920
921        assert_eq!(
922            err(&MessageInstance {
923                m: 2,
924                ..instance.clone()
925            }),
926            Dkim2Error::InstanceSyntax(2)
927        );
928        let mut wrong = instance.clone();
929        wrong.hashes[0].body_hash[0] ^= 1;
930        assert_eq!(
931            err(&wrong),
932            Dkim2Error::BodyHashMismatch {
933                m: 1,
934                hash: Dkim2Hash::Sha256
935            }
936        );
937        let padded = MessageInstance {
938            recipe: Some(Recipe {
939                headers: vec![HeaderRecipe {
940                    name: "from ".to_string(),
941                    steps: vec![Step::Data(vec!["x@test1.dkim2.com".to_string()])],
942                }],
943                body: BodyRecipe::None,
944            }),
945            ..instance.clone()
946        };
947        assert_eq!(err(&padded), Dkim2Error::RecipeSyntax);
948        signer
949            .sign_with_message_instance(&message, Some(&instance), hop(), None)
950            .unwrap();
951    }
952
953    fn load_ed25519(domain: &str, selector: &str) -> Ed25519Key {
954        let pem = std::fs::read(resource(&[
955            "keys",
956            &format!("{selector}._domainkey.{domain}.pem"),
957        ]))
958        .unwrap();
959        let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
960            panic!("expected PKCS8 key");
961        };
962        Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
963    }
964
965    fn load_rsa(domain: &str, selector: &str) -> RsaKey<Sha256> {
966        let pem = std::fs::read(resource(&[
967            "keys",
968            &format!("{selector}._domainkey.{domain}.pem"),
969        ]))
970        .unwrap();
971        RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(&pem).unwrap()).unwrap()
972    }
973
974    fn prepend(signed: &Dkim2Signed, message: &[u8]) -> Vec<u8> {
975        let mut out = Vec::with_capacity(message.len() + 512);
976        signed.write(&mut out);
977        out.extend_from_slice(message);
978        out
979    }
980
981    fn run_vector<K: Into<DkimKey>>(
982        key: K,
983        domain: &str,
984        selector: &str,
985        email: &str,
986        mail_from: &str,
987        rcpt_to: &[&str],
988        expected_file: &str,
989    ) {
990        run_signer_vector(
991            Dkim2Signer::from_key(key).domain(domain).selector(selector),
992            email,
993            mail_from,
994            rcpt_to,
995            expected_file,
996        );
997    }
998
999    fn run_signer_vector(
1000        signer: Dkim2Signer<crate::signer::Ready>,
1001        email: &str,
1002        mail_from: &str,
1003        rcpt_to: &[&str],
1004        expected_file: &str,
1005    ) {
1006        let input_raw = std::fs::read(resource(&["emails", email])).unwrap();
1007        let input = input_raw.as_slice().try_into().unwrap();
1008        let instance = MessageInstance::from_message(&input, None, &signer.hashes).unwrap();
1009        let signed = signer
1010            .sign_internal(
1011                &input,
1012                Hop::real(mail_from, rcpt_to),
1013                instance.as_ref(),
1014                None,
1015                TIMESTAMP,
1016            )
1017            .map(|signature| Dkim2Signed {
1018                message_instance: instance,
1019                signature,
1020            })
1021            .unwrap();
1022        let normalized = normalize_crlf(&input_raw);
1023        let produced = prepend(&signed, normalized.as_ref());
1024
1025        if std::env::var("REGEN_DKIM2").is_ok() {
1026            std::fs::write(resource(&["expected", expected_file]), &produced).unwrap();
1027            return;
1028        }
1029        if signed
1030            .signature
1031            .s
1032            .iter()
1033            .any(|value| value.a == Algorithm::MlDsa44.into())
1034        {
1035            return;
1036        }
1037
1038        let mut expected = std::fs::read(resource(&["expected", expected_file])).unwrap();
1039        if expected.ends_with(b"\r") {
1040            expected.push(b'\n');
1041        }
1042        assert_eq!(
1043            String::from_utf8_lossy(&produced),
1044            String::from_utf8_lossy(&expected),
1045            "vector {expected_file}"
1046        );
1047    }
1048
1049    // SHA-512 hashes and ML-DSA-44; ML-DSA signatures are randomized and checked by the verifier tests.
1050    #[test]
1051    fn sign_golden_dkim2_07() {
1052        let recipient: &[&str] = &["recipient@example.com"];
1053        let ed25519 = || {
1054            Dkim2Signer::from_key(load_ed25519("test1.dkim2.com", "ed25519"))
1055                .domain("test1.dkim2.com")
1056                .selector("ed25519")
1057        };
1058        run_signer_vector(
1059            ed25519().hashes([Dkim2Hash::Sha512]),
1060            "simple.eml",
1061            "sender@test1.dkim2.com",
1062            recipient,
1063            "simple-sha512.eml",
1064        );
1065        run_signer_vector(
1066            ed25519().hashes([Dkim2Hash::Sha256, Dkim2Hash::Sha512]),
1067            "simple.eml",
1068            "sender@test1.dkim2.com",
1069            recipient,
1070            "simple-sha256-sha512.eml",
1071        );
1072        let pem = std::fs::read(resource(&[
1073            "keys",
1074            "mldsa44._domainkey.test1.dkim2.com.pem",
1075        ]))
1076        .unwrap();
1077        let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
1078            panic!("expected PKCS8 key");
1079        };
1080        run_signer_vector(
1081            Dkim2Signer::from_key(MlDsa44Key::from_pkcs8_der(der.secret_pkcs8_der()).unwrap())
1082                .domain("test1.dkim2.com")
1083                .selector("mldsa44"),
1084            "simple.eml",
1085            "sender@test1.dkim2.com",
1086            recipient,
1087            "simple-mldsa44.eml",
1088        );
1089    }
1090
1091    #[test]
1092    fn sign_golden_single_hop() {
1093        let recipient: &[&str] = &["recipient@example.com"];
1094
1095        run_vector(
1096            load_ed25519("test1.dkim2.com", "ed25519"),
1097            "test1.dkim2.com",
1098            "ed25519",
1099            "simple.eml",
1100            "sender@test1.dkim2.com",
1101            recipient,
1102            "simple-ed25519.eml",
1103        );
1104        run_vector(
1105            load_rsa("test1.dkim2.com", "sel1"),
1106            "test1.dkim2.com",
1107            "sel1",
1108            "simple.eml",
1109            "sender@test1.dkim2.com",
1110            recipient,
1111            "simple-rsa2048.eml",
1112        );
1113        run_vector(
1114            load_rsa("test1.dkim2.com", "sel2"),
1115            "test1.dkim2.com",
1116            "sel2",
1117            "simple.eml",
1118            "sender@test1.dkim2.com",
1119            recipient,
1120            "simple-sel2.eml",
1121        );
1122        run_vector(
1123            load_rsa("test1.dkim2.com", "sel3"),
1124            "test1.dkim2.com",
1125            "sel3",
1126            "simple.eml",
1127            "sender@test1.dkim2.com",
1128            recipient,
1129            "simple-sel3.eml",
1130        );
1131        run_vector(
1132            load_ed25519("test2.dkim2.com", "ed25519"),
1133            "test2.dkim2.com",
1134            "ed25519",
1135            "multiheader.eml",
1136            "sender@test2.dkim2.com",
1137            recipient,
1138            "multiheader-ed25519.eml",
1139        );
1140        run_vector(
1141            load_ed25519("test3.dkim2.com", "ed25519"),
1142            "test3.dkim2.com",
1143            "ed25519",
1144            "trailingblank.eml",
1145            "sender@test3.dkim2.com",
1146            recipient,
1147            "trailingblank-ed25519.eml",
1148        );
1149        run_vector(
1150            load_ed25519("test4.dkim2.com", "ed25519"),
1151            "test4.dkim2.com",
1152            "ed25519",
1153            "emptybody.eml",
1154            "sender@test4.dkim2.com",
1155            recipient,
1156            "emptybody-ed25519.eml",
1157        );
1158        run_vector(
1159            load_ed25519("test5.dkim2.com", "ed25519"),
1160            "test5.dkim2.com",
1161            "ed25519",
1162            "multirecipient.eml",
1163            "sender@test5.dkim2.com",
1164            &[
1165                "alice@example.com",
1166                "bob@example.com",
1167                "charlie@example.com",
1168            ],
1169            "multirecipient-ed25519.eml",
1170        );
1171        run_vector(
1172            load_ed25519("test1.dkim2.com", "ed25519"),
1173            "test1.dkim2.com",
1174            "ed25519",
1175            "simple.eml",
1176            "<>",
1177            recipient,
1178            "dsn-ed25519.eml",
1179        );
1180        run_vector(
1181            load_ed25519("test1.dkim2.com", "ed25519"),
1182            "test1.dkim2.com",
1183            "ed25519",
1184            "dupheaders.eml",
1185            "sender@test1.dkim2.com",
1186            recipient,
1187            "dupheaders-ed25519.eml",
1188        );
1189    }
1190
1191    #[test]
1192    fn sign_sequence_overflow_returns_error_not_panic() {
1193        let pkcs8 = Ed25519Key::generate_pkcs8().unwrap();
1194        let key = Ed25519Key::from_pkcs8_der(&pkcs8).unwrap();
1195        let signer = Dkim2Signer::from_key(key)
1196            .domain("ex.com")
1197            .selector("sel")
1198            .out_of_band_arrangement();
1199
1200        let msg = concat!(
1201            "DKIM2-Signature: i=4294967295; m=4294967295; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:ed25519-sha256:QQ==;\r\n",
1202            "Message-Instance: m=4294967295; h=sha256:QQ==:Qg==;\r\n",
1203            "From: a@ex.com\r\n",
1204            "\r\n",
1205            "body\r\n",
1206        );
1207        let result = signer.sign_at(
1208            msg.as_bytes(),
1209            Hop::real("a@ex.com", ["b@x.com"]),
1210            None,
1211            1000,
1212        );
1213        assert_eq!(
1214            result.unwrap_err(),
1215            crate::Error::Dkim2(crate::dkim2::Dkim2Error::SequenceOverflow)
1216        );
1217    }
1218}