Skip to main content

use_dkim/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when DKIM metadata is invalid.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum DkimError {
10    /// The supplied value was empty.
11    Empty,
12    /// The supplied value was invalid for this primitive.
13    InvalidValue,
14    /// The supplied label was not recognized.
15    UnknownLabel,
16}
17
18impl fmt::Display for DkimError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("DKIM value cannot be empty"),
22            Self::InvalidValue => formatter.write_str("invalid DKIM value"),
23            Self::UnknownLabel => formatter.write_str("unknown DKIM label"),
24        }
25    }
26}
27
28impl Error for DkimError {}
29
30macro_rules! dkim_text_newtype {
31    ($name:ident, $doc:literal) => {
32        #[doc = $doc]
33        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34        pub struct $name(String);
35
36        impl $name {
37            /// Creates validated DKIM metadata text.
38            pub fn new(value: impl AsRef<str>) -> Result<Self, DkimError> {
39                validate_dkim_text(value.as_ref()).map(|value| Self(value.to_owned()))
40            }
41
42            /// Returns the stored text.
43            #[must_use]
44            pub fn as_str(&self) -> &str {
45                &self.0
46            }
47        }
48
49        impl fmt::Display for $name {
50            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51                formatter.write_str(self.as_str())
52            }
53        }
54
55        impl FromStr for $name {
56            type Err = DkimError;
57
58            fn from_str(value: &str) -> Result<Self, Self::Err> {
59                Self::new(value)
60            }
61        }
62    };
63}
64
65dkim_text_newtype!(DkimSelector, "DKIM selector metadata.");
66dkim_text_newtype!(DkimDomain, "DKIM signing domain metadata.");
67dkim_text_newtype!(DkimHeaderTag, "DKIM signed header tag metadata.");
68dkim_text_newtype!(DkimBodyHash, "DKIM body hash metadata.");
69
70/// DKIM signature algorithm label.
71#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
72pub enum DkimAlgorithm {
73    /// `rsa-sha256`.
74    #[default]
75    RsaSha256,
76    /// `ed25519-sha256`.
77    Ed25519Sha256,
78    /// `rsa-sha1`, retained as vocabulary metadata for older signatures.
79    RsaSha1,
80}
81
82impl DkimAlgorithm {
83    /// Returns the algorithm label.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::RsaSha256 => "rsa-sha256",
88            Self::Ed25519Sha256 => "ed25519-sha256",
89            Self::RsaSha1 => "rsa-sha1",
90        }
91    }
92}
93
94impl fmt::Display for DkimAlgorithm {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str(self.as_str())
97    }
98}
99
100impl FromStr for DkimAlgorithm {
101    type Err = DkimError;
102
103    fn from_str(value: &str) -> Result<Self, Self::Err> {
104        match value.trim().to_ascii_lowercase().as_str() {
105            "rsa-sha256" => Ok(Self::RsaSha256),
106            "ed25519-sha256" => Ok(Self::Ed25519Sha256),
107            "rsa-sha1" => Ok(Self::RsaSha1),
108            "" => Err(DkimError::Empty),
109            _ => Err(DkimError::UnknownLabel),
110        }
111    }
112}
113
114/// DKIM canonicalization mode.
115#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
116pub enum DkimCanonicalization {
117    /// `simple` canonicalization.
118    Simple,
119    /// `relaxed` canonicalization.
120    #[default]
121    Relaxed,
122}
123
124impl DkimCanonicalization {
125    /// Returns the canonicalization label.
126    #[must_use]
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::Simple => "simple",
130            Self::Relaxed => "relaxed",
131        }
132    }
133}
134
135impl fmt::Display for DkimCanonicalization {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter.write_str(self.as_str())
138    }
139}
140
141impl FromStr for DkimCanonicalization {
142    type Err = DkimError;
143
144    fn from_str(value: &str) -> Result<Self, Self::Err> {
145        match value.trim().to_ascii_lowercase().as_str() {
146            "simple" => Ok(Self::Simple),
147            "relaxed" => Ok(Self::Relaxed),
148            "" => Err(DkimError::Empty),
149            _ => Err(DkimError::UnknownLabel),
150        }
151    }
152}
153
154/// Ordered list of DKIM signed header names.
155#[derive(Clone, Debug, Default, Eq, PartialEq)]
156pub struct DkimSignedHeaders {
157    headers: Vec<DkimHeaderTag>,
158}
159
160impl DkimSignedHeaders {
161    /// Creates an empty signed-header list.
162    #[must_use]
163    pub const fn new() -> Self {
164        Self {
165            headers: Vec::new(),
166        }
167    }
168
169    /// Creates a signed-header list from header names.
170    pub fn from_names<I, S>(names: I) -> Result<Self, DkimError>
171    where
172        I: IntoIterator<Item = S>,
173        S: AsRef<str>,
174    {
175        let mut headers = Self::new();
176        for name in names {
177            headers.headers.push(DkimHeaderTag::new(name)?);
178        }
179        Ok(headers)
180    }
181
182    /// Returns stored header tags.
183    #[must_use]
184    pub fn as_slice(&self) -> &[DkimHeaderTag] {
185        &self.headers
186    }
187
188    /// Returns true when no signed headers are stored.
189    #[must_use]
190    pub fn is_empty(&self) -> bool {
191        self.headers.is_empty()
192    }
193}
194
195impl fmt::Display for DkimSignedHeaders {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        for (index, header) in self.headers.iter().enumerate() {
198            if index > 0 {
199                formatter.write_str(":")?;
200            }
201            write!(formatter, "{header}")?;
202        }
203        Ok(())
204    }
205}
206
207/// DKIM signature metadata. This type does not sign or verify data.
208#[derive(Clone, Debug, Eq, PartialEq)]
209pub struct DkimSignature {
210    selector: DkimSelector,
211    domain: DkimDomain,
212    algorithm: DkimAlgorithm,
213    canonicalization: DkimCanonicalization,
214    signed_headers: DkimSignedHeaders,
215    body_hash: Option<DkimBodyHash>,
216}
217
218impl DkimSignature {
219    /// Creates DKIM signature metadata for a selector and domain.
220    pub fn new(selector: DkimSelector, domain: impl AsRef<str>) -> Result<Self, DkimError> {
221        Ok(Self {
222            selector,
223            domain: DkimDomain::new(domain)?,
224            algorithm: DkimAlgorithm::default(),
225            canonicalization: DkimCanonicalization::default(),
226            signed_headers: DkimSignedHeaders::new(),
227            body_hash: None,
228        })
229    }
230
231    /// Sets the algorithm.
232    #[must_use]
233    pub const fn with_algorithm(mut self, algorithm: DkimAlgorithm) -> Self {
234        self.algorithm = algorithm;
235        self
236    }
237
238    /// Sets the canonicalization mode.
239    #[must_use]
240    pub const fn with_canonicalization(mut self, canonicalization: DkimCanonicalization) -> Self {
241        self.canonicalization = canonicalization;
242        self
243    }
244
245    /// Sets the signed header list.
246    #[must_use]
247    pub fn with_signed_headers(mut self, signed_headers: DkimSignedHeaders) -> Self {
248        self.signed_headers = signed_headers;
249        self
250    }
251
252    /// Sets the body hash metadata.
253    #[must_use]
254    pub fn with_body_hash(mut self, body_hash: DkimBodyHash) -> Self {
255        self.body_hash = Some(body_hash);
256        self
257    }
258
259    /// Returns the selector.
260    #[must_use]
261    pub const fn selector(&self) -> &DkimSelector {
262        &self.selector
263    }
264
265    /// Returns the signing domain.
266    #[must_use]
267    pub const fn domain(&self) -> &DkimDomain {
268        &self.domain
269    }
270}
271
272impl fmt::Display for DkimSignature {
273    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(
275            formatter,
276            "v=1; a={}; c={}; d={}; s={}",
277            self.algorithm, self.canonicalization, self.domain, self.selector
278        )?;
279        if !self.signed_headers.is_empty() {
280            write!(formatter, "; h={}", self.signed_headers)?;
281        }
282        if let Some(body_hash) = &self.body_hash {
283            write!(formatter, "; bh={body_hash}")?;
284        }
285        Ok(())
286    }
287}
288
289fn validate_dkim_text(value: &str) -> Result<&str, DkimError> {
290    let trimmed = value.trim();
291    if trimmed.is_empty() {
292        return Err(DkimError::Empty);
293    }
294    if trimmed.chars().any(|character| {
295        character.is_control() || character.is_whitespace() || matches!(character, ';' | '=')
296    }) {
297        return Err(DkimError::InvalidValue);
298    }
299    Ok(trimmed)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::{
305        DkimAlgorithm, DkimBodyHash, DkimCanonicalization, DkimError, DkimSelector, DkimSignature,
306        DkimSignedHeaders,
307    };
308
309    #[test]
310    fn builds_signature_metadata() -> Result<(), DkimError> {
311        let signature = DkimSignature::new(DkimSelector::new("mail")?, "example.com")?
312            .with_algorithm(DkimAlgorithm::RsaSha256)
313            .with_canonicalization(DkimCanonicalization::Relaxed)
314            .with_signed_headers(DkimSignedHeaders::from_names(["from", "subject"])?)
315            .with_body_hash(DkimBodyHash::new("abc123")?);
316
317        assert_eq!(signature.selector().as_str(), "mail");
318        assert!(signature.to_string().contains("h=from:subject"));
319        assert!(signature.to_string().contains("bh=abc123"));
320        Ok(())
321    }
322
323    #[test]
324    fn parses_labels() -> Result<(), DkimError> {
325        assert_eq!(
326            "ed25519-sha256".parse::<DkimAlgorithm>()?,
327            DkimAlgorithm::Ed25519Sha256
328        );
329        assert_eq!(
330            "simple".parse::<DkimCanonicalization>()?,
331            DkimCanonicalization::Simple
332        );
333        Ok(())
334    }
335}