1use std::sync::{Arc, OnceLock};
4
5use mailrs_arc::{
6 ArcChain, ArcSealCv, ArcSigningKey, Canon as ArcCanon, ChainOutcome, SealOpts,
7 seal as arc_seal, verify_chain_with_crypto,
8};
9use mailrs_dkim::{
10 Canon as DkimCanon, DkimSigningKey, HickoryDkimResolver, RsaSigningKey, SignOpts,
11 sign as dkim_sign, verify_all,
12};
13
14#[derive(Debug, Clone, Default)]
26pub struct DkimSignConfig {
27 pub selector: String,
29 pub domain: String,
31 pub private_key_pem: String,
33 #[doc(hidden)]
41 pub parsed_key: Arc<OnceLock<Result<RsaSigningKey, String>>>,
42}
43
44impl DkimSignConfig {
45 fn rsa_key(&self) -> Result<&RsaSigningKey, String> {
49 let cached = self
50 .parsed_key
51 .get_or_init(|| load_rsa_key(&self.private_key_pem));
52 match cached {
53 Ok(k) => Ok(k),
54 Err(e) => Err(e.clone()),
55 }
56 }
57
58 pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, String> {
60 let rsa = self.rsa_key()?;
61 let key = DkimSigningKey::Rsa(rsa.clone());
62 let opts = SignOpts {
63 domain: self.domain.clone(),
64 selector: self.selector.clone(),
65 signed_headers: ["From", "To", "Subject", "Date", "Message-ID"]
66 .iter()
67 .map(|&s| s.to_string())
68 .collect(),
69 canon_header: DkimCanon::Relaxed,
70 canon_body: DkimCanon::Relaxed,
71 identity: None,
72 timestamp: None,
73 expiration: None,
74 body_length: None,
75 };
76 let header = dkim_sign(message, &key, &opts)
77 .map_err(|e| format!("DKIM signing failed: {e}"))?;
78 let mut signed = Vec::with_capacity(header.len() + message.len());
79 signed.extend_from_slice(header.as_bytes());
80 signed.extend_from_slice(message);
81 Ok(signed)
82 }
83}
84
85pub fn extract_domain(email: &str) -> Option<&str> {
87 email.rsplit_once('@').map(|(_, domain)| domain)
88}
89
90pub async fn arc_seal_message(
93 dkim_config: &DkimSignConfig,
94 dkim_resolver: &HickoryDkimResolver,
95 message: &[u8],
96) -> Result<Vec<u8>, String> {
97 let dkim_outputs = verify_all(dkim_resolver, message).await;
99
100 let prior_chain = ArcChain::extract(message)
103 .map_err(|e| format!("ARC chain extract failed: {e}"))?;
104 let cv = match prior_chain.as_ref() {
105 None => ArcSealCv::None,
106 Some(chain) => {
107 match verify_chain_with_crypto(chain, dkim_resolver, message)
108 .await
109 .map_err(|e| format!("ARC chain verify failed: {e}"))?
110 {
111 ChainOutcome::Pass => ArcSealCv::Pass,
112 _ => ArcSealCv::Fail,
113 }
114 }
115 };
116
117 let authres = build_authres_body(&dkim_config.domain, &dkim_outputs);
120
121 let rsa = dkim_config.rsa_key()?;
124 let key = ArcSigningKey::Rsa(rsa);
125 let opts = SealOpts {
126 domain: dkim_config.domain.clone(),
127 selector: dkim_config.selector.clone(),
128 signed_headers: ["From", "To", "Subject", "Date", "Message-ID", "DKIM-Signature"]
129 .iter()
130 .map(|&s| s.to_string())
131 .collect(),
132 canon_header: ArcCanon::Relaxed,
133 canon_body: ArcCanon::Relaxed,
134 cv,
135 authres,
136 timestamp: None,
137 };
138 let sealed = arc_seal(message, &key, &opts, prior_chain.as_ref())
139 .map_err(|e| format!("ARC sealing failed: {e}"))?;
140
141 let prepend = sealed.concat();
142 let mut out = Vec::with_capacity(prepend.len() + message.len());
143 out.extend_from_slice(prepend.as_bytes());
144 out.extend_from_slice(message);
145 Ok(out)
146}
147
148fn load_rsa_key(pem: &str) -> Result<RsaSigningKey, String> {
149 RsaSigningKey::from_pkcs8_pem(pem).map_err(|e| format!("failed to parse DKIM key: {e}"))
150}
151
152fn build_authres_body(
153 authserv_id: &str,
154 outputs: &[mailrs_dkim::SignatureOutput],
155) -> String {
156 let mut s = String::with_capacity(64);
157 s.push_str(authserv_id);
158 if outputs.is_empty() {
159 s.push_str("; dkim=none");
160 return s;
161 }
162 for o in outputs {
163 let verdict = if o.is_pass() { "pass" } else { "fail" };
164 let d = o.domain();
165 if d.is_empty() {
166 s.push_str(&format!("; dkim={verdict}"));
167 } else {
168 s.push_str(&format!("; dkim={verdict} header.d={d}"));
169 }
170 }
171 s
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 const TEST_RSA_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
179MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNZMkvBc/kAdQl\n\
180GFY6ADYW+guQCJU4x6Zulb4/4fMDUHruL/DR722wV+qKmivIP5SS5X7H+U5X6xha\n\
1811r70zJpdpEzyVZtctBZzm1BkKq81BVdL3iJCbVmPPqs2pUOjGsInmM7gEfvhz7CB\n\
182q+RQ1fb9iGlBA/WmNqLKiVg1nEVDai6DHzEofI+Ta8ij5yGnYHVLJLsqmJotyvHN\n\
1832vi/7kIFigjW/4TOQLcaZGm8AYTEBH4opqvb8C460vLjUFBHeoSqm0vkHWzrwNQx\n\
184S29LczFc/WIpQkl1rx5iS8E5QI2u4eCHVElAjZp4IJsyPYBGVN72mi37IGfEjkHS\n\
185O2TIUEQhAgMBAAECggEABK/ZlWydB1dxV11cTluF4HVZQTKo8RBBQIHDQyLtUDSM\n\
186cZX/eVLs3lrLO9lzyVCGG+oHwBl0y7XOKvh+iAiJNzzSEq+YaX+kiYPQTFDbCasz\n\
187CESr5HcpVYb5EjioN/ca2ht3EQ7oAAmkvfjFr4CKb9Omjzi/aMkTYurKbALCY9zk\n\
188bx8J9VADe1aAAA54WFxIlJvb72Hrfw8iflFqVZNzykRp6tUvJJgSqLOpfM0ut5zb\n\
1890ClgCjSZ7HpehjWVm3KBAOcC7p2TL3erpWoG9BuatgYLLRhW/AzLzXZ3/hSu9kEn\n\
190ihws+VXkHxeaIafrck0HQyWnHb9QEcSgfVIhAYztlwKBgQD3O684316go2e6Qf4I\n\
1917rF4JwmQiI+NMAQq55AwquZkfuw0N2F9AgyuzGskYvI9Ok+l/wP1e8Mb6JRuP6Nj\n\
192dPYTQwzfmyZgdOovxGkZOGE60EQuX/1IS/NbLKQySAphgBVR2FlHnu+VMvha61tm\n\
193/5K1ROAB3Ng3FbR7rHJXFjWU+wKBgQDUrUtS3Yj0yHnxA/AL04lsxNrLlinEVDM1\n\
1946wPjC2VEXhj2j4JNrVqXG4GVYYEGhkUTjwcTOiZfmHaqMzEFo1aTOoiLrMMLQjmm\n\
195jPNkLHsDXcbG5FA0BbzQmlj+ixKPToh2gHfeMfH96YmdROfmvY/TN9yI1FgkLErL\n\
196YKatCKWokwKBgC6z25nGuD1oIMQSi0ZssKGd3jSrV1K4a1EfhSFsZzE8uKn0fDn9\n\
197FSBABU1OU6w1Q657yeephWXUPZXF97tl8MYauGfVCx7Vdxem5qOY/uT5SqfoAhSS\n\
198JFpoyGunKC7a3ywizlq1L1Tj1/50z0NZrAEKDbbMXRuqwflKzh6dV2nZAoGBAImh\n\
199N6yBdr7J+bfRz4cntrgv0FONcqv9vUI4O0SzvC35Ivh0OGPiOkytXTd5aND7FTqq\n\
200BW8Y43pbpPdRt3ipkj4m0/RnsbTYf4xbjKqX6mdsSVWurIRt7hmkuNDI2RLqRH9D\n\
201dc7RzYN+nTKsQ9Jbe/a5ILtfh0apbyGcA2DYxrOHAoGAYYm/jwilVVaH1xSlP52w\n\
202BcpT8g8Wqgo4wFOTcyGJScBeFnQO1dhap+KNxCOyM/b2a8p2kQxHPmhIt+iyUpsM\n\
203Wob7+tvQ4QgOJAUWByTxMHczAY8Vrl45gxYS29ahbuvjtjPVLgHcaFnZPfun8i6u\n\
204/qw9cba4IgRYuEuLJ9bzbAY=\n\
205-----END PRIVATE KEY-----";
206
207 fn test_config() -> DkimSignConfig {
208 DkimSignConfig {
209 selector: "test".into(),
210 domain: "example.com".into(),
211 private_key_pem: TEST_RSA_KEY.into(),
212 ..Default::default()
213 }
214 }
215
216 fn simple_message() -> Vec<u8> {
217 b"From: sender@example.com\r\n\
218 To: recipient@example.com\r\n\
219 Subject: Test\r\n\
220 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
221 Message-ID: <test@example.com>\r\n\
222 \r\n\
223 Hello, world!\r\n"
224 .to_vec()
225 }
226
227 #[test]
228 fn extract_domain_valid() {
229 assert_eq!(extract_domain("user@example.com"), Some("example.com"));
230 }
231
232 #[test]
233 fn extract_domain_no_at() {
234 assert_eq!(extract_domain("nope"), None);
235 }
236
237 #[test]
238 fn extract_domain_empty_string() {
239 assert_eq!(extract_domain(""), None);
240 }
241
242 #[test]
243 fn extract_domain_at_only() {
244 assert_eq!(extract_domain("@"), Some(""));
245 }
246
247 #[test]
248 fn extract_domain_multiple_at_signs_uses_last() {
249 assert_eq!(extract_domain("user@host@example.com"), Some("example.com"));
250 }
251
252 #[test]
253 fn extract_domain_subdomain() {
254 assert_eq!(
255 extract_domain("postmaster@mail.sub.example.com"),
256 Some("mail.sub.example.com")
257 );
258 }
259
260 #[test]
261 fn dkim_sign_invalid_pem_returns_error() {
262 let cfg = DkimSignConfig {
263 selector: "selector".into(),
264 domain: "example.com".into(),
265 private_key_pem: "not-a-valid-pem".into(),
266 ..Default::default()
267 };
268 let result = cfg.sign(b"From: test@example.com\r\n\r\nbody");
269 assert!(result.is_err());
270 let err = result.unwrap_err();
271 assert!(
272 err.contains("failed to parse DKIM key"),
273 "unexpected error: {err}"
274 );
275 }
276
277 #[test]
278 fn dkim_sign_empty_pem_returns_error() {
279 let cfg = DkimSignConfig {
280 selector: "sel".into(),
281 domain: "example.com".into(),
282 private_key_pem: String::new(),
283 ..Default::default()
284 };
285 let result = cfg.sign(b"From: test@example.com\r\n\r\n");
286 assert!(result.is_err());
287 }
288
289 #[test]
290 fn dkim_config_fields_stored() {
291 let cfg = DkimSignConfig {
292 selector: "myselector".into(),
293 domain: "mydomain.com".into(),
294 private_key_pem: "pem-data".into(),
295 ..Default::default()
296 };
297 assert_eq!(cfg.selector, "myselector");
298 assert_eq!(cfg.domain, "mydomain.com");
299 assert_eq!(cfg.private_key_pem, "pem-data");
300 }
301
302 #[test]
303 fn dkim_sign_prepends_dkim_signature_header() {
304 let cfg = test_config();
305 let msg = simple_message();
306 let signed = cfg.sign(&msg).unwrap();
307 let signed_str = String::from_utf8_lossy(&signed);
308 assert!(
309 signed_str.starts_with("DKIM-Signature:"),
310 "signed message must start with DKIM-Signature header"
311 );
312 }
313
314 #[test]
315 fn dkim_signature_contains_required_tags() {
316 let cfg = test_config();
317 let msg = simple_message();
318 let signed = cfg.sign(&msg).unwrap();
319 let signed_str = String::from_utf8_lossy(&signed);
320
321 let dkim_header = signed_str
322 .split_once("From: sender@example.com")
323 .expect("original message must follow signature")
324 .0;
325
326 assert!(dkim_header.contains("v=1"), "missing v= tag");
327 assert!(dkim_header.contains("a=rsa-sha256"), "missing a= tag");
328 assert!(dkim_header.contains("d=example.com"), "missing d= tag");
329 assert!(dkim_header.contains("s=test"), "missing s= tag");
330 assert!(dkim_header.contains("b="), "missing b= tag");
331 assert!(dkim_header.contains("bh="), "missing bh= tag");
332 assert!(dkim_header.contains("h="), "missing h= tag");
333 }
334
335 #[test]
336 fn dkim_signature_header_list_contains_signed_fields() {
337 let cfg = test_config();
338 let msg = simple_message();
339 let signed = cfg.sign(&msg).unwrap();
340 let signed_str = String::from_utf8_lossy(&signed);
341
342 let h_start = signed_str.find("h=").expect("h= tag missing");
343 let after_h = &signed_str[h_start..];
344 let h_value = after_h.split_once(';').map(|(v, _)| v).unwrap_or(after_h);
345
346 let h_lower = h_value.to_lowercase();
347 for expected in ["from", "to", "subject", "date", "message-id"] {
348 assert!(
349 h_lower.contains(expected),
350 "h= tag missing expected header: {expected}"
351 );
352 }
353 }
354
355 #[test]
356 fn dkim_sign_preserves_original_message() {
357 let cfg = test_config();
358 let msg = simple_message();
359 let signed = cfg.sign(&msg).unwrap();
360 assert!(
361 signed.ends_with(&msg),
362 "signed output must end with the original message bytes"
363 );
364 }
365
366 #[test]
367 fn dkim_sign_output_is_larger_than_input() {
368 let cfg = test_config();
369 let msg = simple_message();
370 let signed = cfg.sign(&msg).unwrap();
371 assert!(
372 signed.len() > msg.len(),
373 "signed message must be larger due to prepended header"
374 );
375 }
376
377 #[test]
378 fn dkim_sign_same_input_produces_same_body_hash() {
379 let cfg = test_config();
380 let msg = simple_message();
381 let signed1 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
382 let signed2 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
383
384 let extract_bh = |s: &str| -> String {
385 let start = s.find("bh=").unwrap() + 3;
386 let end = s[start..].find(';').map(|i| start + i).unwrap_or(s.len());
387 s[start..end].trim().to_string()
388 };
389
390 assert_eq!(
391 extract_bh(&signed1),
392 extract_bh(&signed2),
393 "body hash must be deterministic for identical input"
394 );
395 }
396
397 #[test]
398 fn dkim_sign_empty_body() {
399 let cfg = test_config();
400 let msg = b"From: sender@example.com\r\n\
401 To: recipient@example.com\r\n\
402 Subject: Empty body\r\n\
403 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
404 Message-ID: <empty@example.com>\r\n\
405 \r\n";
406 let result = cfg.sign(msg);
407 assert!(result.is_ok(), "signing an empty body must succeed");
408 let signed = result.unwrap();
409 let signed_str = String::from_utf8_lossy(&signed);
410 assert!(signed_str.starts_with("DKIM-Signature:"));
411 }
412
413 #[test]
414 fn dkim_sign_large_message() {
415 let cfg = test_config();
416 let headers = b"From: sender@example.com\r\n\
417 To: recipient@example.com\r\n\
418 Subject: Large message test\r\n\
419 Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
420 Message-ID: <large@example.com>\r\n\
421 \r\n";
422 let body_line = b"The quick brown fox jumps over the lazy dog. \r\n";
423 let repeat_count = 1_000_000 / body_line.len();
424 let mut msg = headers.to_vec();
425 for _ in 0..repeat_count {
426 msg.extend_from_slice(body_line);
427 }
428
429 let result = cfg.sign(&msg);
430 assert!(result.is_ok(), "signing a ~1MB message must succeed");
431 }
432
433 #[test]
434 fn dkim_sign_custom_domain_and_selector() {
435 let cfg = DkimSignConfig {
436 selector: "mail2025".into(),
437 domain: "custom-mail.example.org".into(),
438 private_key_pem: TEST_RSA_KEY.into(),
439 ..Default::default()
440 };
441 let msg = simple_message();
442 let signed = cfg.sign(&msg).unwrap();
443 let signed_str = String::from_utf8_lossy(&signed);
444 assert!(signed_str.contains("d=custom-mail.example.org"));
445 assert!(signed_str.contains("s=mail2025"));
446 }
447
448 #[test]
449 fn build_authres_empty_outputs() {
450 let s = build_authres_body("mx.example.com", &[]);
451 assert_eq!(s, "mx.example.com; dkim=none");
452 }
453
454 #[test]
455 fn build_authres_starts_with_authserv_id() {
456 let s = build_authres_body("mx.example.com", &[]);
457 assert!(s.starts_with("mx.example.com"));
458 }
459}