Skip to main content

mailrs_outbound_queue/
dkim_sign.rs

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/// DKIM signing configuration.
10#[derive(Debug, Clone)]
11pub struct DkimSignConfig {
12    /// DKIM selector — the label under `<selector>._domainkey.<domain>`.
13    pub selector: String,
14    /// Signing domain (matches the `d=` tag in the DKIM-Signature header).
15    pub domain: String,
16    /// Private RSA key in PKCS#8 PEM form.
17    pub private_key_pem: String,
18}
19
20impl DkimSignConfig {
21    /// sign a message, prepending the DKIM-Signature header
22    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
43/// extract domain from email address
44pub fn extract_domain(email: &str) -> Option<&str> {
45    email.rsplit_once('@').map(|(_, domain)| domain)
46}
47
48/// ARC-seal a forwarded message, preserving authentication chain (RFC 8617)
49pub 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    // verify existing DKIM signatures (for auth results)
58    let dkim_results = authenticator.verify_dkim(&auth_msg).await;
59
60    // verify existing ARC chain
61    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    // build Authentication-Results for ARC-Authentication-Results header
67    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    // create ARC seal using the DKIM key
72    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    // prepend ARC headers to message
85    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    // --- extract_domain tests ---
147
148    #[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        // "@" splits into ("", "") → domain is ""
166        assert_eq!(extract_domain("@"), Some(""));
167    }
168
169    #[test]
170    fn extract_domain_multiple_at_signs_uses_last() {
171        // rsplit_once('@') takes the last '@'
172        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    // --- error path tests ---
189
190    #[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    // --- signature header format tests ---
227
228    #[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        // extract just the DKIM-Signature header (up to the original message)
248        let dkim_header = signed_str
249            .split_once("From: sender@example.com")
250            .expect("original message must follow signature")
251            .0;
252
253        // RFC 6376 required tags
254        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        // find the h= tag value
271        let h_start = signed_str.find("h=").expect("h= tag missing");
272        let after_h = &signed_str[h_start..];
273        // h= value ends at the next ';' or end of header
274        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    // --- original message preservation ---
289
290    #[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    // --- determinism ---
313
314    #[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        // extract bh= values
322        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    // --- empty body ---
336
337    #[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        // per RFC 6376, the body hash of an empty body (after canonicalization
356        // of "\r\n") with sha-256 is always the same
357        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    // --- large message ---
379
380    #[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        // ~1MB body
390        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    // --- special characters in headers ---
406
407    #[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        // header folding per RFC 5322 — long header continued on next line
439        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    // --- different domain/selector ---
467
468    #[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    // --- config clone ---
483
484    #[test]
485    fn dkim_config_clone_is_independent() {
486        let cfg1 = test_config();
487        let cfg2 = cfg1.clone();
488        // both produce valid signatures
489        let msg = simple_message();
490        let signed1 = cfg1.sign(&msg).unwrap();
491        let signed2 = cfg2.sign(&msg).unwrap();
492        // body hashes are identical (same key, same message)
493        assert_eq!(
494            signed1.len(),
495            signed2.len(),
496            "cloned config must produce identical output"
497        );
498    }
499
500    // --- multipart mime ---
501
502    #[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    // --- body with only whitespace ---
529
530    #[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    // --- bare minimum message ---
545
546    #[test]
547    fn dkim_sign_minimal_message() {
548        let cfg = test_config();
549        // only From header and empty body
550        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}