kumo_dkim/
sign.rs

1use crate::header::DKIMHeaderBuilder;
2use crate::{canonicalization, hash, DKIMError, DkimPrivateKey, HeaderList, ParsedEmail, HEADER};
3use data_encoding::BASE64;
4use ed25519_dalek::Signer as _;
5use std::sync::Arc;
6
7/// Builder for the Signer
8pub struct SignerBuilder {
9    signed_headers: Option<Vec<String>>,
10    private_key: Option<Arc<DkimPrivateKey>>,
11    selector: Option<String>,
12    signing_domain: Option<String>,
13    time: Option<chrono::DateTime<chrono::offset::Utc>>,
14    header_canonicalization: canonicalization::Type,
15    body_canonicalization: canonicalization::Type,
16    expiry: Option<chrono::Duration>,
17    over_sign: bool,
18}
19
20impl SignerBuilder {
21    /// New builder
22    pub fn new() -> Self {
23        Self {
24            signed_headers: None,
25            private_key: None,
26            selector: None,
27            signing_domain: None,
28            expiry: None,
29            time: None,
30            over_sign: false,
31
32            header_canonicalization: canonicalization::Type::Simple,
33            body_canonicalization: canonicalization::Type::Simple,
34        }
35    }
36
37    /// Specify headers to be used in the DKIM signature
38    /// The From: header is required.
39    pub fn with_signed_headers(
40        mut self,
41        headers: impl IntoIterator<Item = impl Into<String>>,
42    ) -> Result<Self, DKIMError> {
43        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
44
45        if !headers.iter().any(|h| h.eq_ignore_ascii_case("from")) {
46            return Err(DKIMError::BuilderError("missing From in signed headers"));
47        }
48
49        self.signed_headers = Some(headers);
50        Ok(self)
51    }
52
53    /// Enable automatic over-signing. Each configured header in the list of headers
54    /// to sign will be counted in the message, and it will be signed N+1 times
55    /// so that the resultant signature will be proof against a replay attack
56    /// that inserts an additional header of the same name.
57    pub fn with_over_signing(mut self, over_sign: bool) -> Self {
58        self.over_sign = over_sign;
59        self
60    }
61
62    /// Specify the private key used to sign the email
63    pub fn with_private_key<K: Into<Arc<DkimPrivateKey>>>(mut self, key: K) -> Self {
64        self.private_key = Some(key.into());
65        self
66    }
67
68    /// Specify the private key used to sign the email
69    pub fn with_selector(mut self, value: impl Into<String>) -> Self {
70        self.selector = Some(value.into());
71        self
72    }
73
74    /// Specify for which domain the email should be signed for
75    pub fn with_signing_domain(mut self, value: impl Into<String>) -> Self {
76        self.signing_domain = Some(value.into());
77        self
78    }
79
80    /// Specify the header canonicalization
81    pub fn with_header_canonicalization(mut self, value: canonicalization::Type) -> Self {
82        self.header_canonicalization = value;
83        self
84    }
85
86    /// Specify the body canonicalization
87    pub fn with_body_canonicalization(mut self, value: canonicalization::Type) -> Self {
88        self.body_canonicalization = value;
89        self
90    }
91
92    /// Specify current time. Mostly used for testing
93    pub fn with_time(mut self, value: chrono::DateTime<chrono::offset::Utc>) -> Self {
94        self.time = Some(value);
95        self
96    }
97
98    /// Specify a expiry duration for the signature validity
99    pub fn with_expiry(mut self, value: chrono::Duration) -> Self {
100        self.expiry = Some(value);
101        self
102    }
103
104    /// Build an instance of the Signer
105    /// Must be provided: signed_headers, private_key, selector and
106    /// signing_domain.
107    pub fn build(self) -> Result<Signer, DKIMError> {
108        use DKIMError::BuilderError;
109
110        let private_key = self
111            .private_key
112            .ok_or(BuilderError("missing required private key"))?;
113        let hash_algo = match &*private_key {
114            DkimPrivateKey::OpenSSLRsa(_) => hash::HashAlgo::RsaSha256,
115            DkimPrivateKey::Ed25519(_) => hash::HashAlgo::Ed25519Sha256,
116        };
117
118        Ok(Signer {
119            signed_headers: HeaderList::new(
120                self.signed_headers
121                    .ok_or(BuilderError("missing required signed headers"))?,
122            ),
123            private_key,
124            selector: self
125                .selector
126                .ok_or(BuilderError("missing required selector"))?,
127            signing_domain: self
128                .signing_domain
129                .ok_or(BuilderError("missing required signing domain"))?,
130            header_canonicalization: self.header_canonicalization,
131            body_canonicalization: self.body_canonicalization,
132            expiry: self.expiry,
133            hash_algo,
134            time: self.time,
135            over_sign: self.over_sign,
136        })
137    }
138}
139
140impl Default for SignerBuilder {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146#[derive(Debug)]
147pub struct Signer {
148    signed_headers: HeaderList,
149    private_key: Arc<DkimPrivateKey>,
150    selector: String,
151    signing_domain: String,
152    header_canonicalization: canonicalization::Type,
153    body_canonicalization: canonicalization::Type,
154    expiry: Option<chrono::Duration>,
155    hash_algo: hash::HashAlgo,
156    time: Option<chrono::DateTime<chrono::offset::Utc>>,
157    over_sign: bool,
158}
159
160/// DKIM signer. Use the [SignerBuilder] to build an instance.
161impl Signer {
162    /// Sign a message
163    /// As specified in <https://datatracker.ietf.org/doc/html/rfc6376#section-5>
164    pub fn sign<'b>(&self, email: &'b ParsedEmail<'b>) -> Result<String, DKIMError> {
165        let over_sign_header_list;
166        let effective_header_list = if self.over_sign {
167            over_sign_header_list = self.signed_headers.compute_over_signed(email);
168            &over_sign_header_list
169        } else {
170            &self.signed_headers
171        };
172
173        let body_hash = self.compute_body_hash(email)?;
174        let dkim_header_builder = self.dkim_header_builder(&body_hash, effective_header_list)?;
175
176        let header_hash =
177            self.compute_header_hash(email, effective_header_list, dkim_header_builder.clone())?;
178
179        let signature = match &*self.private_key {
180            DkimPrivateKey::Ed25519(signing_key) => {
181                signing_key.sign(&header_hash).to_bytes().into()
182            }
183            DkimPrivateKey::OpenSSLRsa(private_key) => {
184                use foreign_types::ForeignType;
185
186                let mut siglen = private_key.size();
187                let mut sigbuf = vec![0u8; siglen as usize];
188
189                // We need to grub around a bit to call into RSA_sign:
190                // The higher level wrappers available in the openssl
191                // crate only include EVP_DigestSign which doesn't
192                // accept a pre-calculated digest like we have here.
193
194                let status = unsafe {
195                    openssl_sys::RSA_sign(
196                        match self.hash_algo {
197                            hash::HashAlgo::RsaSha1 => openssl_sys::NID_sha1,
198                            hash::HashAlgo::RsaSha256 => openssl_sys::NID_sha256,
199                            hash => {
200                                return Err(DKIMError::UnsupportedHashAlgorithm(format!(
201                                    "{:?}",
202                                    hash
203                                )))
204                            }
205                        },
206                        header_hash.as_ptr(),
207                        header_hash.len() as _,
208                        // unsafety: sigbuf must be >= siglen in size
209                        sigbuf.as_mut_ptr(),
210                        &mut siglen,
211                        private_key.as_ptr(),
212                    )
213                };
214
215                if status != 1 || siglen == 0 {
216                    return Err(DKIMError::FailedToSign(format!(
217                        "RSA_sign failed status={status} siglen={siglen} {:?}",
218                        openssl::error::Error::get()
219                    )));
220                }
221
222                sigbuf.truncate(siglen as usize);
223                sigbuf
224            }
225        };
226
227        // add the signature into the DKIM header and generate the header
228        let dkim_header = dkim_header_builder
229            .add_tag("b", &BASE64.encode(&signature))
230            .build();
231
232        Ok(format!("{}: {}", HEADER, dkim_header.raw_bytes))
233    }
234
235    fn dkim_header_builder(
236        &self,
237        body_hash: &str,
238        effective_header_list: &HeaderList,
239    ) -> Result<DKIMHeaderBuilder, DKIMError> {
240        let now = chrono::offset::Utc::now();
241
242        let mut builder = DKIMHeaderBuilder::new()
243            .add_tag("v", "1")
244            .add_tag("a", self.hash_algo.algo_name())
245            .add_tag("d", &self.signing_domain)
246            .add_tag("s", &self.selector)
247            .add_tag(
248                "c",
249                &format!(
250                    "{}/{}",
251                    self.header_canonicalization.canon_name(),
252                    self.body_canonicalization.canon_name()
253                ),
254            )
255            .add_tag("bh", body_hash)
256            .set_signed_headers(effective_header_list);
257        if let Some(time) = self.time {
258            builder = builder.set_time(time);
259        } else {
260            builder = builder.set_time(now);
261        }
262        if let Some(expiry) = self.expiry {
263            builder = builder.set_expiry(expiry)?;
264        }
265
266        Ok(builder)
267    }
268
269    fn compute_body_hash<'b>(&self, email: &'b ParsedEmail<'b>) -> Result<String, DKIMError> {
270        let length = None;
271        let canonicalization = self.body_canonicalization;
272        hash::compute_body_hash(canonicalization, length, self.hash_algo, email)
273    }
274
275    fn compute_header_hash<'b>(
276        &self,
277        email: &'b ParsedEmail<'b>,
278        effective_header_list: &HeaderList,
279        dkim_header_builder: DKIMHeaderBuilder,
280    ) -> Result<Vec<u8>, DKIMError> {
281        let canonicalization = self.header_canonicalization;
282
283        // For signing the DKIM-Signature header the signature needs to be null
284        let dkim_header = dkim_header_builder.add_tag("b", "").build();
285
286        hash::compute_headers_hash(
287            canonicalization,
288            effective_header_list,
289            self.hash_algo,
290            &dkim_header,
291            email,
292        )
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use chrono::TimeZone;
300    use std::fs;
301
302    #[test]
303    fn test_over_sign_rsa() {
304        let raw_email = r#"Subject: subject
305From: Sven Sauleau <sven@cloudflare.com>
306
307Hello Alice
308        "#
309        .replace("\n", "\r\n");
310        let email = ParsedEmail::parse(raw_email).unwrap();
311
312        let private_key = DkimPrivateKey::rsa_key_file("./test/keys/2022.private").unwrap();
313        let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
314
315        let signer = SignerBuilder::new()
316            .with_signed_headers(["From", "Subject"])
317            .unwrap()
318            .with_private_key(private_key)
319            .with_selector("s20")
320            .with_signing_domain("example.com")
321            .with_time(time)
322            .with_over_signing(true)
323            .build()
324            .unwrap();
325        let header = signer.sign(&email).unwrap();
326
327        k9::snapshot!(
328            header,
329            r#"
330DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s20; c=simple/simple;\r
331\tbh=KXQwQpX2zFwgixPbV6Dd18ZMJU04lLeRnwqzUp8uGwI=;\r
332\th=from:from:subject:subject; t=1609459201;\r
333\tb=RIi7B309UuepQL7XMSlbGxdAQR6suGh6aiLwXFY+7q+JkuB/Le3a4OL9nvF5jZ8sM84D2o/JR\r
334\t4G9scGNr9CzdtvPFRiAQJvo7RfMmMwIYKWdvVEzdsm83h/P04FzU8sHBUONNc6LPfl65nMQuLbE\r
335\tXJc+5grPAvvFTyAN3F7z/ZTGVNDS2SAHMwwACCEq1zzmqjMAiBm6KpBQCN3siYsIwOgiBbk8Vwz\r
336\tv4auuTPeeHQNE1luTpZhakC6SxX+iiBo1sHIoU+3J9gU0ye2QescQNPWFCw53XSqlYUtNsEx8OB\r
337\tQyUd7c5MfN/w29d1CCCtPqJfnKvy2CkVUbavPPdMVdBw==;
338"#
339        );
340    }
341
342    #[test]
343    fn test_sign_rsa() {
344        let raw_email = r#"Subject: subject
345From: Sven Sauleau <sven@cloudflare.com>
346
347Hello Alice
348        "#
349        .replace("\n", "\r\n");
350        let email = ParsedEmail::parse(raw_email).unwrap();
351
352        let private_key = DkimPrivateKey::rsa_key_file("./test/keys/2022.private").unwrap();
353        let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
354
355        let signer = SignerBuilder::new()
356            .with_signed_headers(["From", "Subject"])
357            .unwrap()
358            .with_private_key(private_key)
359            .with_selector("s20")
360            .with_signing_domain("example.com")
361            .with_time(time)
362            .build()
363            .unwrap();
364        let header = signer.sign(&email).unwrap();
365
366        k9::snapshot!(
367            header,
368            r#"
369DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s20; c=simple/simple;\r
370\tbh=KXQwQpX2zFwgixPbV6Dd18ZMJU04lLeRnwqzUp8uGwI=;\r
371\th=from:subject; t=1609459201;\r
372\tb=jWvcCA6TzqyFbpitXBo2barOzu7ObOcPg5jqqdekMdHTxR2XoAGGtQ9NUDVqxJoifZvOIfElh\r
373\tT7717zandgj4HSL0nldmfhLHECN43Ktk3dfpSid5KPZQJddQBVwrH6qUXPoAk9THhuZx8KP/PdM\r
374\tedlRuNYixoMtZynSl8VfWOjMQohanxafYUtIG+p2DYCq82uzVOLy87mvQBk8IWooNk1rDTHkj5U\r
375\t03xSRjPuEUZqkQKJzYcPV+L9TE3jX7HmuCzRpY9fn3G0xp/YhJFD7FuGr47vZLzMRaqqov5BTJw\r
376\tTgKxK8IE0fuYkF7e1LUYbEzZqdtSLxgmzCuz+efLY38w==;
377"#
378        );
379    }
380
381    #[test]
382    fn test_sign_rsa_openssl() {
383        let raw_email = r#"Subject: subject
384From: Sven Sauleau <sven@cloudflare.com>
385
386Hello Alice
387        "#
388        .replace("\n", "\r\n");
389        let email = ParsedEmail::parse(raw_email).unwrap();
390
391        let data = std::fs::read("./test/keys/2022.private").unwrap();
392        let pkey = openssl::rsa::Rsa::private_key_from_pem(&data).unwrap();
393
394        let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
395
396        let signer = SignerBuilder::new()
397            .with_signed_headers([
398                "From",
399                "Subject",
400                "List-Unsubscribe",
401                "List-Unsubscribe-Post",
402                "X-Lets-Make-This-Really-Long",
403            ])
404            .unwrap()
405            .with_private_key(DkimPrivateKey::OpenSSLRsa(pkey))
406            .with_selector("s20")
407            .with_signing_domain("example.com")
408            .with_time(time)
409            .build()
410            .unwrap();
411        let header = signer.sign(&email).unwrap();
412
413        k9::snapshot!(
414            header,
415            r#"
416DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s20; c=simple/simple;\r
417\tbh=KXQwQpX2zFwgixPbV6Dd18ZMJU04lLeRnwqzUp8uGwI=;\r
418\th=from:subject:list-unsubscribe:list-unsubscribe-post:\r
419\t\tx-lets-make-this-really-long; t=1609459201;\r
420\tb=kNEseaF1ozpjc3/BnUgXqRjl99TIOmxnIlXzQEGu9B3HkUmiZM3sY9jkoqo3x44DlxZv2sEsd\r
421\todQQ8NivIvruQb7tkgRrnhB+54fVh7mfxiG3q1CB3fFkz13FPU85UkE/Y5HozEfjfSBiBDMnguv\r
422\tZyh/M4SVbDAXxBeQWHVVggkUQoyRy7X9vdlK3vRWQq+mdFINEUITKSI6GAUJdtWDTUad3/DnOm5\r
423\tykzWZkIcX7u+ng2jXC7wI+cko4+dLzdy9SIKaL1rEqdiF+IDRnR1yLDBZjQXUyzPkLYKzmrOAsb\r
424\tF1E9z34xwGjT0F3+TKbcupxg8mHnn0QBU8PXCKb+NYbQ==;
425"#
426        );
427    }
428
429    #[test]
430    fn test_sign_ed25519() {
431        let raw_email = r#"From: Joe SixPack <joe@football.example.com>
432To: Suzie Q <suzie@shopping.example.net>
433Subject: Is dinner ready?
434Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
435Message-ID: <20030712040037.46341.5F8J@football.example.com>
436
437Hi.
438
439We lost the game.  Are you hungry yet?
440
441Joe."#
442            .replace('\n', "\r\n");
443        let email = ParsedEmail::parse(raw_email).unwrap();
444
445        let file_content = fs::read("./test/keys/ed.private").unwrap();
446        let file_decoded = BASE64.decode(&file_content).unwrap();
447        let mut key_bytes = [0u8; ed25519_dalek::SECRET_KEY_LENGTH];
448        key_bytes.copy_from_slice(&file_decoded);
449        let secret_key = ed25519_dalek::SigningKey::from_bytes(&key_bytes);
450
451        let time = chrono::Utc
452            .with_ymd_and_hms(2018, 6, 10, 13, 38, 29)
453            .unwrap();
454
455        let signer = SignerBuilder::new()
456            .with_signed_headers([
457                "From",
458                "To",
459                "Subject",
460                "Date",
461                "Message-ID",
462                "From",
463                "Subject",
464                "Date",
465            ])
466            .unwrap()
467            .with_private_key(DkimPrivateKey::Ed25519(secret_key))
468            .with_body_canonicalization(canonicalization::Type::Relaxed)
469            .with_header_canonicalization(canonicalization::Type::Relaxed)
470            .with_selector("brisbane")
471            .with_signing_domain("football.example.com")
472            .with_time(time)
473            .build()
474            .unwrap();
475        let header = signer.sign(&email).unwrap();
476
477        k9::snapshot!(
478            header,
479            r#"
480DKIM-Signature: v=1; a=ed25519-sha256; d=football.example.com; s=brisbane;\r
481\tc=relaxed/relaxed; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r
482\th=from:to:subject:date:message-id:from:subject:date; t=1528637909;\r
483\tb=wITr2H3sBuBfMsnUwlRTO7Oq/C/jd2vubDm50DrXtMFEBLRiz9GfrgCozcg764+gYqWXV3Snd\r
484\t1ynYh8sJ5BXBg==;
485"#
486        );
487    }
488}