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
12pub 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 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 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 pub fn with_over_signing(mut self, over_sign: bool) -> Self {
63 self.over_sign = over_sign;
64 self
65 }
66
67 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 pub fn with_selector(mut self, value: impl Into<String>) -> Self {
75 self.selector = Some(value.into());
76 self
77 }
78
79 pub fn with_signing_domain(mut self, value: impl Into<String>) -> Self {
81 self.signing_domain = Some(value.into());
82 self
83 }
84
85 pub fn with_header_canonicalization(mut self, value: canonicalization::Type) -> Self {
87 self.header_canonicalization = value;
88 self
89 }
90
91 pub fn with_body_canonicalization(mut self, value: canonicalization::Type) -> Self {
93 self.body_canonicalization = value;
94 self
95 }
96
97 pub fn with_time(mut self, value: chrono::DateTime<chrono::offset::Utc>) -> Self {
99 self.time = Some(value);
100 self
101 }
102
103 pub fn with_expiry(mut self, value: chrono::Duration) -> Self {
105 self.expiry = Some(value);
106 self
107 }
108
109 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
165impl Signer {
167 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 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 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 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 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}