1use mail_auth::arc::ArcSealer;
2use mail_auth::common::crypto::{RsaKey, Sha256};
3use mail_auth::common::headers::HeaderWriter;
4use mail_auth::dkim::DkimSigner;
5use mail_auth::{AuthenticatedMessage, AuthenticationResults, MessageAuthenticator};
6use rustls_pki_types::pem::PemObject;
7use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
8
9#[derive(Debug, Clone)]
11pub struct DkimSignConfig {
12 pub selector: String,
14 pub domain: String,
16 pub private_key_pem: String,
18}
19
20impl DkimSignConfig {
21 pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, String> {
23 let pkcs8 = PrivatePkcs8KeyDer::from_pem_slice(self.private_key_pem.as_bytes())
24 .map_err(|e| format!("failed to parse DKIM PEM: {e}"))?;
25 let key = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs8(pkcs8))
26 .map_err(|e| format!("failed to load DKIM key: {e}"))?;
27
28 let signature = DkimSigner::from_key(key)
29 .domain(&self.domain)
30 .selector(&self.selector)
31 .headers(["From", "To", "Subject", "Date", "Message-ID"])
32 .sign(message)
33 .map_err(|e| format!("DKIM signing failed: {e}"))?;
34
35 let header = signature.to_header();
36 let mut signed = Vec::with_capacity(header.len() + message.len());
37 signed.extend_from_slice(header.as_bytes());
38 signed.extend_from_slice(message);
39 Ok(signed)
40 }
41}
42
43pub fn extract_domain(email: &str) -> Option<&str> {
45 email.rsplit_once('@').map(|(_, domain)| domain)
46}
47
48pub async fn arc_seal_message(
50 dkim_config: &DkimSignConfig,
51 authenticator: &MessageAuthenticator,
52 message: &[u8],
53) -> Result<Vec<u8>, String> {
54 let auth_msg = AuthenticatedMessage::parse(message)
55 .ok_or("failed to parse message for ARC sealing")?;
56
57 let dkim_results = authenticator.verify_dkim(&auth_msg).await;
59
60 let arc_output = authenticator.verify_arc(&auth_msg).await;
62 if !arc_output.can_be_sealed() {
63 return Err("ARC chain cannot be sealed (invalid chain)".into());
64 }
65
66 let header_from = auth_msg.from();
68 let auth_results = AuthenticationResults::new(&dkim_config.domain)
69 .with_dkim_results(&dkim_results, header_from);
70
71 let pkcs8 = PrivatePkcs8KeyDer::from_pem_slice(dkim_config.private_key_pem.as_bytes())
73 .map_err(|e| format!("failed to parse DKIM PEM for ARC: {e}"))?;
74 let key = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs8(pkcs8))
75 .map_err(|e| format!("failed to load key for ARC: {e}"))?;
76
77 let arc_set = ArcSealer::from_key(key)
78 .domain(&dkim_config.domain)
79 .selector(&dkim_config.selector)
80 .headers(["From", "To", "Subject", "Date", "Message-ID", "DKIM-Signature"])
81 .seal(&auth_msg, &auth_results, &arc_output)
82 .map_err(|e| format!("ARC sealing failed: {e}"))?;
83
84 let arc_header = arc_set.to_header();
86 let ar_header = auth_results.to_header();
87 let mut sealed = Vec::with_capacity(arc_header.len() + ar_header.len() + message.len());
88 sealed.extend_from_slice(arc_header.as_bytes());
89 sealed.extend_from_slice(ar_header.as_bytes());
90 sealed.extend_from_slice(message);
91 Ok(sealed)
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 const TEST_RSA_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
99MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNZMkvBc/kAdQl\n\
100GFY6ADYW+guQCJU4x6Zulb4/4fMDUHruL/DR722wV+qKmivIP5SS5X7H+U5X6xha\n\
1011r70zJpdpEzyVZtctBZzm1BkKq81BVdL3iJCbVmPPqs2pUOjGsInmM7gEfvhz7CB\n\
102q+RQ1fb9iGlBA/WmNqLKiVg1nEVDai6DHzEofI+Ta8ij5yGnYHVLJLsqmJotyvHN\n\
1032vi/7kIFigjW/4TOQLcaZGm8AYTEBH4opqvb8C460vLjUFBHeoSqm0vkHWzrwNQx\n\
104S29LczFc/WIpQkl1rx5iS8E5QI2u4eCHVElAjZp4IJsyPYBGVN72mi37IGfEjkHS\n\
105O2TIUEQhAgMBAAECggEABK/ZlWydB1dxV11cTluF4HVZQTKo8RBBQIHDQyLtUDSM\n\
106cZX/eVLs3lrLO9lzyVCGG+oHwBl0y7XOKvh+iAiJNzzSEq+YaX+kiYPQTFDbCasz\n\
107CESr5HcpVYb5EjioN/ca2ht3EQ7oAAmkvfjFr4CKb9Omjzi/aMkTYurKbALCY9zk\n\
108bx8J9VADe1aAAA54WFxIlJvb72Hrfw8iflFqVZNzykRp6tUvJJgSqLOpfM0ut5zb\n\
1090ClgCjSZ7HpehjWVm3KBAOcC7p2TL3erpWoG9BuatgYLLRhW/AzLzXZ3/hSu9kEn\n\
110ihws+VXkHxeaIafrck0HQyWnHb9QEcSgfVIhAYztlwKBgQD3O684316go2e6Qf4I\n\
1117rF4JwmQiI+NMAQq55AwquZkfuw0N2F9AgyuzGskYvI9Ok+l/wP1e8Mb6JRuP6Nj\n\
112dPYTQwzfmyZgdOovxGkZOGE60EQuX/1IS/NbLKQySAphgBVR2FlHnu+VMvha61tm\n\
113/5K1ROAB3Ng3FbR7rHJXFjWU+wKBgQDUrUtS3Yj0yHnxA/AL04lsxNrLlinEVDM1\n\
1146wPjC2VEXhj2j4JNrVqXG4GVYYEGhkUTjwcTOiZfmHaqMzEFo1aTOoiLrMMLQjmm\n\
115jPNkLHsDXcbG5FA0BbzQmlj+ixKPToh2gHfeMfH96YmdROfmvY/TN9yI1FgkLErL\n\
116YKatCKWokwKBgC6z25nGuD1oIMQSi0ZssKGd3jSrV1K4a1EfhSFsZzE8uKn0fDn9\n\
117FSBABU1OU6w1Q657yeephWXUPZXF97tl8MYauGfVCx7Vdxem5qOY/uT5SqfoAhSS\n\
118JFpoyGunKC7a3ywizlq1L1Tj1/50z0NZrAEKDbbMXRuqwflKzh6dV2nZAoGBAImh\n\
119N6yBdr7J+bfRz4cntrgv0FONcqv9vUI4O0SzvC35Ivh0OGPiOkytXTd5aND7FTqq\n\
120BW8Y43pbpPdRt3ipkj4m0/RnsbTYf4xbjKqX6mdsSVWurIRt7hmkuNDI2RLqRH9D\n\
121dc7RzYN+nTKsQ9Jbe/a5ILtfh0apbyGcA2DYxrOHAoGAYYm/jwilVVaH1xSlP52w\n\
122BcpT8g8Wqgo4wFOTcyGJScBeFnQO1dhap+KNxCOyM/b2a8p2kQxHPmhIt+iyUpsM\n\
123Wob7+tvQ4QgOJAUWByTxMHczAY8Vrl45gxYS29ahbuvjtjPVLgHcaFnZPfun8i6u\n\
124/qw9cba4IgRYuEuLJ9bzbAY=\n\
125-----END PRIVATE KEY-----";
126
127 fn test_config() -> DkimSignConfig {
128 DkimSignConfig {
129 selector: "test".into(),
130 domain: "example.com".into(),
131 private_key_pem: TEST_RSA_KEY.into(),
132 }
133 }
134
135 fn simple_message() -> Vec<u8> {
136 b"From: sender@example.com\r\n\
137 To: recipient@example.com\r\n\
138 Subject: Test\r\n\
139 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
140 Message-ID: <test@example.com>\r\n\
141 \r\n\
142 Hello, world!\r\n"
143 .to_vec()
144 }
145
146 #[test]
149 fn extract_domain_valid() {
150 assert_eq!(extract_domain("user@example.com"), Some("example.com"));
151 }
152
153 #[test]
154 fn extract_domain_no_at() {
155 assert_eq!(extract_domain("nope"), None);
156 }
157
158 #[test]
159 fn extract_domain_empty_string() {
160 assert_eq!(extract_domain(""), None);
161 }
162
163 #[test]
164 fn extract_domain_at_only() {
165 assert_eq!(extract_domain("@"), Some(""));
167 }
168
169 #[test]
170 fn extract_domain_multiple_at_signs_uses_last() {
171 assert_eq!(extract_domain("user@host@example.com"), Some("example.com"));
173 }
174
175 #[test]
176 fn extract_domain_subdomain() {
177 assert_eq!(
178 extract_domain("postmaster@mail.sub.example.com"),
179 Some("mail.sub.example.com")
180 );
181 }
182
183 #[test]
184 fn extract_domain_local_only() {
185 assert_eq!(extract_domain("no-domain@"), Some(""));
186 }
187
188 #[test]
191 fn dkim_sign_invalid_pem_returns_error() {
192 let cfg = DkimSignConfig {
193 selector: "selector".into(),
194 domain: "example.com".into(),
195 private_key_pem: "not-a-valid-pem".into(),
196 };
197 let result = cfg.sign(b"From: test@example.com\r\n\r\nbody");
198 assert!(result.is_err());
199 let err = result.unwrap_err();
200 assert!(err.contains("failed to parse DKIM PEM"), "unexpected error: {err}");
201 }
202
203 #[test]
204 fn dkim_sign_empty_pem_returns_error() {
205 let cfg = DkimSignConfig {
206 selector: "sel".into(),
207 domain: "example.com".into(),
208 private_key_pem: String::new(),
209 };
210 let result = cfg.sign(b"From: test@example.com\r\n\r\n");
211 assert!(result.is_err());
212 }
213
214 #[test]
215 fn dkim_config_fields_stored() {
216 let cfg = DkimSignConfig {
217 selector: "myselector".into(),
218 domain: "mydomain.com".into(),
219 private_key_pem: "pem-data".into(),
220 };
221 assert_eq!(cfg.selector, "myselector");
222 assert_eq!(cfg.domain, "mydomain.com");
223 assert_eq!(cfg.private_key_pem, "pem-data");
224 }
225
226 #[test]
229 fn dkim_sign_prepends_dkim_signature_header() {
230 let cfg = test_config();
231 let msg = simple_message();
232 let signed = cfg.sign(&msg).unwrap();
233 let signed_str = String::from_utf8_lossy(&signed);
234 assert!(
235 signed_str.starts_with("DKIM-Signature:"),
236 "signed message must start with DKIM-Signature header"
237 );
238 }
239
240 #[test]
241 fn dkim_signature_contains_required_tags() {
242 let cfg = test_config();
243 let msg = simple_message();
244 let signed = cfg.sign(&msg).unwrap();
245 let signed_str = String::from_utf8_lossy(&signed);
246
247 let dkim_header = signed_str
249 .split_once("From: sender@example.com")
250 .expect("original message must follow signature")
251 .0;
252
253 assert!(dkim_header.contains("v=1"), "missing v= tag");
255 assert!(dkim_header.contains("a=rsa-sha256"), "missing a= tag");
256 assert!(dkim_header.contains("d=example.com"), "missing d= tag");
257 assert!(dkim_header.contains("s=test"), "missing s= tag");
258 assert!(dkim_header.contains("b="), "missing b= (signature) tag");
259 assert!(dkim_header.contains("bh="), "missing bh= (body hash) tag");
260 assert!(dkim_header.contains("h="), "missing h= (signed headers) tag");
261 }
262
263 #[test]
264 fn dkim_signature_header_list_contains_signed_fields() {
265 let cfg = test_config();
266 let msg = simple_message();
267 let signed = cfg.sign(&msg).unwrap();
268 let signed_str = String::from_utf8_lossy(&signed);
269
270 let h_start = signed_str.find("h=").expect("h= tag missing");
272 let after_h = &signed_str[h_start..];
273 let h_value = after_h
275 .split_once(';')
276 .map(|(v, _)| v)
277 .unwrap_or(after_h);
278
279 let h_lower = h_value.to_lowercase();
280 for expected in ["from", "to", "subject", "date", "message-id"] {
281 assert!(
282 h_lower.contains(expected),
283 "h= tag missing expected header: {expected}"
284 );
285 }
286 }
287
288 #[test]
291 fn dkim_sign_preserves_original_message() {
292 let cfg = test_config();
293 let msg = simple_message();
294 let signed = cfg.sign(&msg).unwrap();
295 assert!(
296 signed.ends_with(&msg),
297 "signed output must end with the original message bytes"
298 );
299 }
300
301 #[test]
302 fn dkim_sign_output_is_larger_than_input() {
303 let cfg = test_config();
304 let msg = simple_message();
305 let signed = cfg.sign(&msg).unwrap();
306 assert!(
307 signed.len() > msg.len(),
308 "signed message must be larger due to prepended header"
309 );
310 }
311
312 #[test]
315 fn dkim_sign_same_input_produces_same_body_hash() {
316 let cfg = test_config();
317 let msg = simple_message();
318 let signed1 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
319 let signed2 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
320
321 let extract_bh = |s: &str| -> String {
323 let start = s.find("bh=").unwrap() + 3;
324 let end = s[start..].find(';').map(|i| start + i).unwrap_or(s.len());
325 s[start..end].trim().to_string()
326 };
327
328 assert_eq!(
329 extract_bh(&signed1),
330 extract_bh(&signed2),
331 "body hash must be deterministic for identical input"
332 );
333 }
334
335 #[test]
338 fn dkim_sign_empty_body() {
339 let cfg = test_config();
340 let msg = b"From: sender@example.com\r\n\
341 To: recipient@example.com\r\n\
342 Subject: Empty body\r\n\
343 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
344 Message-ID: <empty@example.com>\r\n\
345 \r\n";
346 let result = cfg.sign(msg);
347 assert!(result.is_ok(), "signing an empty body must succeed");
348 let signed = result.unwrap();
349 let signed_str = String::from_utf8_lossy(&signed);
350 assert!(signed_str.starts_with("DKIM-Signature:"));
351 }
352
353 #[test]
354 fn dkim_sign_empty_body_has_known_body_hash() {
355 let cfg = test_config();
358 let msg = b"From: a@example.com\r\n\r\n";
359 let signed1 = cfg.sign(msg).unwrap();
360
361 let msg2 = b"From: b@example.com\r\n\r\n";
362 let signed2 = cfg.sign(msg2).unwrap();
363
364 let extract_bh = |data: &[u8]| -> String {
365 let s = String::from_utf8_lossy(data);
366 let start = s.find("bh=").unwrap() + 3;
367 let end = s[start..].find(';').map(|i| start + i).unwrap_or(s.len());
368 s[start..end].trim().to_string()
369 };
370
371 assert_eq!(
372 extract_bh(&signed1),
373 extract_bh(&signed2),
374 "empty body hash must be identical regardless of headers"
375 );
376 }
377
378 #[test]
381 fn dkim_sign_large_message() {
382 let cfg = test_config();
383 let headers = b"From: sender@example.com\r\n\
384 To: recipient@example.com\r\n\
385 Subject: Large message test\r\n\
386 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
387 Message-ID: <large@example.com>\r\n\
388 \r\n";
389 let body_line = b"The quick brown fox jumps over the lazy dog. \r\n";
391 let repeat_count = 1_000_000 / body_line.len();
392 let mut msg = headers.to_vec();
393 for _ in 0..repeat_count {
394 msg.extend_from_slice(body_line);
395 }
396
397 let result = cfg.sign(&msg);
398 assert!(result.is_ok(), "signing a ~1MB message must succeed");
399
400 let signed = result.unwrap();
401 assert!(signed.len() > msg.len());
402 assert!(signed.ends_with(&msg));
403 }
404
405 #[test]
408 fn dkim_sign_utf8_subject() {
409 let cfg = test_config();
410 let msg = b"From: sender@example.com\r\n\
411 To: recipient@example.com\r\n\
412 Subject: =?UTF-8?B?5rWL6K+V5Li76aKY?=\r\n\
413 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
414 Message-ID: <utf8@example.com>\r\n\
415 \r\n\
416 body\r\n";
417 let result = cfg.sign(msg);
418 assert!(result.is_ok(), "signing with MIME-encoded UTF-8 subject must succeed");
419 }
420
421 #[test]
422 fn dkim_sign_special_chars_in_subject() {
423 let cfg = test_config();
424 let msg = b"From: sender@example.com\r\n\
425 To: recipient@example.com\r\n\
426 Subject: Re: [PATCH v2] Fix \"bug\" in <module> & cleanup\r\n\
427 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
428 Message-ID: <special@example.com>\r\n\
429 \r\n\
430 body\r\n";
431 let result = cfg.sign(msg);
432 assert!(result.is_ok(), "signing with special chars in subject must succeed");
433 }
434
435 #[test]
436 fn dkim_sign_long_folded_headers() {
437 let cfg = test_config();
438 let msg = b"From: sender@example.com\r\n\
440 To: recipient@example.com\r\n\
441 Subject: This is a very long subject line that should be folded\r\n\
442 across multiple lines to test header folding behavior\r\n\
443 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
444 Message-ID: <folded@example.com>\r\n\
445 \r\n\
446 body\r\n";
447 let result = cfg.sign(msg);
448 assert!(result.is_ok(), "signing with folded headers must succeed");
449 }
450
451 #[test]
452 fn dkim_sign_multiple_recipients() {
453 let cfg = test_config();
454 let msg = b"From: sender@example.com\r\n\
455 To: alice@example.com, bob@example.com,\r\n\
456 charlie@example.com\r\n\
457 Subject: Group mail\r\n\
458 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
459 Message-ID: <multi@example.com>\r\n\
460 \r\n\
461 body\r\n";
462 let result = cfg.sign(msg);
463 assert!(result.is_ok(), "signing with multiple To recipients must succeed");
464 }
465
466 #[test]
469 fn dkim_sign_custom_domain_and_selector() {
470 let cfg = DkimSignConfig {
471 selector: "mail2025".into(),
472 domain: "custom-mail.example.org".into(),
473 private_key_pem: TEST_RSA_KEY.into(),
474 };
475 let msg = simple_message();
476 let signed = cfg.sign(&msg).unwrap();
477 let signed_str = String::from_utf8_lossy(&signed);
478 assert!(signed_str.contains("d=custom-mail.example.org"));
479 assert!(signed_str.contains("s=mail2025"));
480 }
481
482 #[test]
485 fn dkim_config_clone_is_independent() {
486 let cfg1 = test_config();
487 let cfg2 = cfg1.clone();
488 let msg = simple_message();
490 let signed1 = cfg1.sign(&msg).unwrap();
491 let signed2 = cfg2.sign(&msg).unwrap();
492 assert_eq!(
494 signed1.len(),
495 signed2.len(),
496 "cloned config must produce identical output"
497 );
498 }
499
500 #[test]
503 fn dkim_sign_multipart_mime_message() {
504 let cfg = test_config();
505 let msg = b"From: sender@example.com\r\n\
506 To: recipient@example.com\r\n\
507 Subject: Multipart test\r\n\
508 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
509 Message-ID: <multipart@example.com>\r\n\
510 MIME-Version: 1.0\r\n\
511 Content-Type: multipart/alternative; boundary=\"boundary42\"\r\n\
512 \r\n\
513 --boundary42\r\n\
514 Content-Type: text/plain; charset=utf-8\r\n\
515 \r\n\
516 Plain text body\r\n\
517 --boundary42\r\n\
518 Content-Type: text/html; charset=utf-8\r\n\
519 \r\n\
520 <html><body><p>HTML body</p></body></html>\r\n\
521 --boundary42--\r\n";
522 let result = cfg.sign(msg);
523 assert!(result.is_ok(), "signing a multipart MIME message must succeed");
524 let signed = result.unwrap();
525 assert!(signed.ends_with(msg.as_slice()));
526 }
527
528 #[test]
531 fn dkim_sign_whitespace_only_body() {
532 let cfg = test_config();
533 let msg = b"From: sender@example.com\r\n\
534 To: recipient@example.com\r\n\
535 Subject: Whitespace\r\n\
536 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
537 Message-ID: <ws@example.com>\r\n\
538 \r\n\
539 \r\n \r\n\t\r\n";
540 let result = cfg.sign(msg);
541 assert!(result.is_ok(), "signing whitespace-only body must succeed");
542 }
543
544 #[test]
547 fn dkim_sign_minimal_message() {
548 let cfg = test_config();
549 let msg = b"From: x@example.com\r\n\r\n";
551 let result = cfg.sign(msg);
552 assert!(result.is_ok(), "signing a minimal message must succeed");
553 }
554}