Skip to main content

mail_auth/dkim2/
mod.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//! DomainKeys Identified Mail version 2 (DKIM2).
8//!
9//! Every hop that relays a message adds a `DKIM2-Signature` header field. A
10//! hop that changes the message also adds a `Message-Instance` header field
11//! with hashes of the new content and a recipe that recreates the previous
12//! content. Each signature binds the SMTP envelope (`MAIL FROM` and `RCPT TO`)
13//! used by its hop, or names the domain of the next signer, so a verifier can
14//! follow the chain of custody from the originator to the last hop.
15//!
16//! Implemented specifications:
17//!
18//! - [draft-ietf-dkim-dkim2-spec-08](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/):
19//!   DKIM2 Signatures. Section references (`§`) in this module point to this draft.
20//! - [draft-ietf-dkim-dkim2-dns](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-dns/):
21//!   DKIM2 DNS Records. Pure ML-DSA-44 keys are published with `k=mldsa44`,
22//!   which the DNS draft does not define yet.
23//!
24//! Sign with [`Dkim2Signer`]. Verify a message with
25//! [`MessageAuthenticator::verify_dkim2`](crate::MessageAuthenticator::verify_dkim2)
26//! (or check that it is Provisionally Verified with
27//! [`MessageAuthenticator::verify_dkim2_provisionally`](crate::MessageAuthenticator::verify_dkim2_provisionally))
28//! and an inbound delivery status notification with
29//! [`MessageAuthenticator::verify_dkim2_dsn`](crate::MessageAuthenticator::verify_dkim2_dsn).
30
31use crate::{
32    crypto::{Algorithm, DkimKey, HashAlgorithm},
33    signer::NeedDomain,
34};
35
36pub mod builder;
37pub mod canonicalize;
38pub mod dsn;
39mod error;
40pub mod headers;
41pub mod output;
42pub mod parse;
43pub mod recipe;
44pub mod sign;
45pub mod verify;
46
47#[cfg(test)]
48mod tests;
49
50pub use dsn::{Dkim2Dsn, Dkim2DsnFailure, Dkim2DsnOutput};
51pub use error::Dkim2Error;
52pub use output::{ChainLink, Dkim2Output, Dkim2Result, Verification};
53pub use recipe::{BodyRecipe, HeaderRecipe, Recipe, Step};
54pub use sign::{Dkim2Signed, Envelope, Hop};
55
56/// DKIM2 message signer (§12).
57///
58/// Built in typestate steps. [`Dkim2Signer::from_key`] takes the first
59/// private key, [`domain`](Dkim2Signer::domain) sets the signing domain (`d=`)
60/// and [`selector`](Dkim2Signer::selector) sets the selector of the first key,
61/// which yields a `Dkim2Signer<`[`Ready`](crate::signer::Ready)`>`. Only a
62/// ready signer can sign. Optional settings on a ready signer:
63/// [`additional_key`](Dkim2Signer::additional_key) adds an alternative
64/// signature under another selector (`|`),
65/// [`hybrid_key`](Dkim2Signer::hybrid_key) adds a signature that must pass
66/// together with the previous one (`,`), [`hashes`](Dkim2Signer::hashes)
67/// selects the `Message-Instance` hash algorithms, [`flags`](Dkim2Signer::flags)
68/// sets the `f=` tag, [`nonce`](Dkim2Signer::nonce) sets the `n=` tag and
69/// [`out_of_band_arrangement`](Dkim2Signer::out_of_band_arrangement) allows
70/// signing messages that are not Fully Verified DKIM2 Messages.
71///
72/// The signing methods ([`sign`](Dkim2Signer::sign),
73/// [`sign_revised`](Dkim2Signer::sign_revised),
74/// [`sign_with_recipe`](Dkim2Signer::sign_with_recipe) and
75/// [`sign_with_message_instance`](Dkim2Signer::sign_with_message_instance))
76/// return a [`Dkim2Signed`] holding the header fields to prepend to the
77/// message. A message that already carries DKIM2 header fields is signed only
78/// with the [`Dkim2Output`] of a full verification that passed (§12).
79///
80/// # Example
81///
82/// ```rust,no_run
83/// use mail_auth::{
84///     crypto::{Ed25519Key, RsaKey, Sha256},
85///     dkim2::{Dkim2Signer, Flag, Hop},
86/// };
87/// use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
88///
89/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
90/// # let rsa_pem = "";
91/// # let ed25519_pkcs8: &[u8] = &[];
92/// # let message = "From: sender@example.com\r\n\r\nHello\r\n";
93/// let rsa_key = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(rsa_pem.as_bytes())?)?;
94/// let signed = Dkim2Signer::from_key(rsa_key)
95///     .domain("example.com")
96///     .selector("rsa-sel")
97///     .additional_key(Ed25519Key::from_pkcs8_der(ed25519_pkcs8)?, "ed-sel")
98///     .flags([Flag::Feedback])
99///     .sign(
100///         message.as_bytes(),
101///         Hop::real("sender@example.com", ["recipient@example.org"]),
102///         None,
103///     )?;
104///
105/// let signed_message = format!("{}{}", signed.to_header(), message);
106/// # Ok(())
107/// # }
108/// ```
109pub struct Dkim2Signer<State = NeedDomain> {
110    _state: std::marker::PhantomData<State>,
111    pub(crate) keys: Vec<KeyEntry>,
112    pub(crate) domain: String,
113    pub(crate) hashes: Vec<Dkim2Hash>,
114    pub(crate) flags: Vec<Flag>,
115    pub(crate) nonce: Option<String>,
116    pub(crate) out_of_band: bool,
117}
118
119pub(crate) struct KeyEntry {
120    pub(crate) key: DkimKey,
121    pub(crate) selector: String,
122    pub(crate) group: u32,
123}
124
125/// A parsed `DKIM2-Signature` header field (§10).
126///
127/// Produced by [`Signature::parse`] when a message is parsed into an
128/// [`AuthenticatedMessage`](crate::AuthenticatedMessage), and by the
129/// [`Dkim2Signer`] signing methods in [`Dkim2Signed::signature`]. Verified
130/// signatures are exposed through [`ChainLink::signature`].
131#[derive(Debug, PartialEq, Eq, Clone, Default)]
132pub struct Signature {
133    /// `i=`: sequence number of this signature in the chain, starting at 1
134    /// for the originator (§10.1).
135    pub i: u32,
136    /// `m=`: highest `Message-Instance` revision number (`m=`) present when
137    /// this signature was created (§10.2).
138    pub m: u32,
139    /// `t=`: signature creation time, in seconds since the UNIX epoch (§10.4).
140    pub t: u64,
141    /// `d=`: signing domain, used to look up the public key (§10.8).
142    pub d: String,
143    /// `s=`: one signature value per selector, ordered by
144    /// [`group`](SignatureValue::group) (§10.9).
145    pub s: Vec<SignatureValue>,
146    /// `mf=` and `rt=` (the SMTP envelope of the hop), or `nd=` (the domain
147    /// of the next signer) (§10.5 to §10.7).
148    pub chain: ChainBinding,
149    /// `n=`: opaque nonce meaningful only to the signer, at most 64
150    /// printable ASCII characters (§10.3).
151    pub n: Option<String>,
152    /// `f=`: flags reporting what the signer did or requesting behaviour
153    /// from later hops (§10.10).
154    pub flags: Vec<Flag>,
155}
156
157/// One entry of the `s=` tag of a `DKIM2-Signature` (§10.9).
158///
159/// A signature carries one entry per signing key, which gives algorithmic
160/// agility: each entry names its own selector and algorithm. Entries that
161/// share a [`group`](Self::group) are separated by `,` and all of them must
162/// pass; groups are separated by `|` and the signature passes if any group
163/// does.
164#[derive(Debug, PartialEq, Eq, Clone)]
165pub struct SignatureValue {
166    /// Selector under which the public key is published, queried as
167    /// `<selector>._domainkey.<d>`.
168    pub selector: String,
169    /// Signing algorithm (`rsa-sha256`, `ed25519-sha256` or `mldsa44`), or
170    /// the name of one this crate does not implement.
171    pub a: AlgorithmName,
172    /// Raw signature bytes (base64-decoded).
173    pub b: Vec<u8>,
174    /// Index of the `|`-separated group the entry belongs to, starting at 0.
175    /// Entries of one group are contiguous.
176    pub group: u32,
177}
178
179/// The algorithm name of a [`SignatureValue`]: an algorithm this crate
180/// implements, or the name of one it does not, which never passes (§14.6).
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum AlgorithmName {
183    /// A supported algorithm.
184    Known(Algorithm),
185    /// An unsupported algorithm, kept verbatim.
186    Unknown(Box<str>),
187}
188
189impl AlgorithmName {
190    /// Returns the algorithm name as it appears in the `s=` tag.
191    pub fn as_str(&self) -> &str {
192        match self {
193            AlgorithmName::Known(algorithm) => algorithm.name(),
194            AlgorithmName::Unknown(name) => name,
195        }
196    }
197
198    /// Returns the supported algorithm, or `None` for an unknown name.
199    pub fn known(&self) -> Option<Algorithm> {
200        match self {
201            AlgorithmName::Known(algorithm) => Some(*algorithm),
202            AlgorithmName::Unknown(_) => None,
203        }
204    }
205}
206
207impl From<Algorithm> for AlgorithmName {
208    fn from(algorithm: Algorithm) -> Self {
209        AlgorithmName::Known(algorithm)
210    }
211}
212
213/// How a `DKIM2-Signature` links to the next hop in the chain of custody
214/// (§10.5 to §10.7, §12.2 and §12.3).
215#[derive(Debug, PartialEq, Eq, Clone)]
216pub enum ChainBinding {
217    /// The SMTP envelope used when the signer sent the message (`mf=` and
218    /// `rt=`). Addresses are RFC 5321 paths including the angle brackets;
219    /// `mail_from` is `<>` for a null reverse-path.
220    Envelope {
221        /// `mf=`: the `MAIL FROM` reverse-path.
222        mail_from: String,
223        /// `rt=`: the `RCPT TO` forward-paths.
224        rcpt_to: Vec<String>,
225    },
226    /// `nd=`: domain of the next `DKIM2-Signature`, used for an imaginary hop
227    /// where the message changes domain without an SMTP transaction.
228    NextDomain(String),
229}
230
231impl Default for ChainBinding {
232    fn default() -> Self {
233        ChainBinding::Envelope {
234            mail_from: String::new(),
235            rcpt_to: Vec::new(),
236        }
237    }
238}
239
240/// A value of the `f=` tag of a `DKIM2-Signature` (§10.10). Flag names are
241/// case-sensitive.
242#[derive(Debug, PartialEq, Eq, Clone)]
243pub enum Flag {
244    /// `feedback`: the signer requests feedback about how the message is
245    /// handled.
246    Feedback,
247    /// `feedhere`: feedback should be relayed through this hop instead of
248    /// being sent directly to the requestor.
249    FeedHere,
250    /// `exploded`: the signer is sending this message to more than one
251    /// recipient (§12.5). Not allowed on the first signature (`i=1`).
252    Exploded,
253    /// Any other flag, kept verbatim. Unknown flags have no effect.
254    Unknown(String),
255}
256
257impl Flag {
258    /// Returns the flag as it appears in the `f=` tag.
259    pub fn as_bytes(&self) -> &[u8] {
260        match self {
261            Flag::Feedback => b"feedback",
262            Flag::FeedHere => b"feedhere",
263            Flag::Exploded => b"exploded",
264            Flag::Unknown(value) => value.as_bytes(),
265        }
266    }
267}
268
269/// A parsed `Message-Instance` header field (§9).
270///
271/// Records the hashes of the message content at one revision and, for a
272/// revision made by a Reviser, the recipe that recreates the previous
273/// revision. Produced by [`MessageInstance::parse`] when a message is parsed,
274/// by [`MessageInstance::from_message`] and [`MessageInstance::from_recipe`],
275/// and by the signers in [`Dkim2Signed::message_instance`].
276#[derive(Debug, PartialEq, Eq, Clone, Default)]
277pub struct MessageInstance {
278    /// `m=`: revision number, starting at 1 for the originator (§9.1).
279    pub m: u32,
280    /// `h=`: header and body hashes of this revision, one entry per hash
281    /// algorithm, ordered by [`group`](MessageHash::group) (§9.3).
282    pub hashes: Vec<MessageHash>,
283    /// `r=`: recipe that recreates the previous revision from this one, or
284    /// `None` if the tag is absent (§9.2).
285    pub recipe: Option<Recipe>,
286}
287
288/// A hash algorithm defined for DKIM2 `Message-Instance` hashes (§3.1).
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Dkim2Hash {
291    /// SHA-256 (`sha256`).
292    Sha256,
293    /// SHA-512 (`sha512`).
294    Sha512,
295}
296
297impl Dkim2Hash {
298    /// Parses a hash name. Names are case-sensitive (§2.14).
299    pub fn parse(name: &[u8]) -> Option<Self> {
300        hashify::map!(name, Dkim2Hash,
301            b"sha256" => Dkim2Hash::Sha256,
302            b"sha512" => Dkim2Hash::Sha512,
303        )
304        .copied()
305    }
306
307    /// Returns the hash name used in the `h=` tag.
308    pub fn name(&self) -> &'static str {
309        self.algorithm().name()
310    }
311
312    /// Returns the matching run-time hash algorithm.
313    pub fn algorithm(&self) -> HashAlgorithm {
314        match self {
315            Dkim2Hash::Sha256 => HashAlgorithm::Sha256,
316            Dkim2Hash::Sha512 => HashAlgorithm::Sha512,
317        }
318    }
319}
320
321/// The hash name of a [`MessageHash`]: an algorithm this crate implements, or
322/// the name of one it does not, which never passes (§14.7).
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum HashName {
325    /// A supported algorithm.
326    Known(Dkim2Hash),
327    /// An unsupported algorithm, kept verbatim.
328    Unknown(Box<str>),
329}
330
331impl HashName {
332    /// Returns the hash name as it appears in the `h=` tag.
333    pub fn as_str(&self) -> &str {
334        match self {
335            HashName::Known(hash) => hash.name(),
336            HashName::Unknown(name) => name,
337        }
338    }
339
340    /// Returns the supported algorithm, or `None` for an unknown name.
341    pub fn known(&self) -> Option<Dkim2Hash> {
342        match self {
343            HashName::Known(hash) => Some(*hash),
344            HashName::Unknown(_) => None,
345        }
346    }
347}
348
349impl From<Dkim2Hash> for HashName {
350    fn from(hash: Dkim2Hash) -> Self {
351        HashName::Known(hash)
352    }
353}
354
355/// One entry of the `h=` tag of a `Message-Instance` (§9.3 and §6).
356///
357/// Entries that share a [`group`](Self::group) are separated by `,` and all
358/// of them must pass; groups are separated by `|` and the hashes pass if any
359/// group does.
360#[derive(Debug, PartialEq, Eq, Clone)]
361pub struct MessageHash {
362    /// Hash algorithm name.
363    pub name: HashName,
364    /// Raw digest of the header fields hash (§6.2).
365    pub header_hash: Vec<u8>,
366    /// Raw digest of the body hash (§6.1).
367    pub body_hash: Vec<u8>,
368    /// Index of the `|`-separated group the entry belongs to, starting at 0.
369    /// Entries of one group are contiguous.
370    pub group: u32,
371}
372
373impl Signature {
374    /// Returns the signing domain (`d=`).
375    pub fn domain(&self) -> &str {
376        &self.d
377    }
378}