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
7pub 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 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 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 pub fn with_over_signing(mut self, over_sign: bool) -> Self {
58 self.over_sign = over_sign;
59 self
60 }
61
62 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 pub fn with_selector(mut self, value: impl Into<String>) -> Self {
70 self.selector = Some(value.into());
71 self
72 }
73
74 pub fn with_signing_domain(mut self, value: impl Into<String>) -> Self {
76 self.signing_domain = Some(value.into());
77 self
78 }
79
80 pub fn with_header_canonicalization(mut self, value: canonicalization::Type) -> Self {
82 self.header_canonicalization = value;
83 self
84 }
85
86 pub fn with_body_canonicalization(mut self, value: canonicalization::Type) -> Self {
88 self.body_canonicalization = value;
89 self
90 }
91
92 pub fn with_time(mut self, value: chrono::DateTime<chrono::offset::Utc>) -> Self {
94 self.time = Some(value);
95 self
96 }
97
98 pub fn with_expiry(mut self, value: chrono::Duration) -> Self {
100 self.expiry = Some(value);
101 self
102 }
103
104 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
160impl Signer {
162 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 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 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 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 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}