kumo_dkim/
sign.rs

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