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}