Skip to main content

mailrs_outbound_queue/
dkim_sign.rs

1//! Outbound DKIM signing + ARC sealing, on the mailrs-* crates.
2
3use 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/// DKIM signing configuration.
15///
16/// The PKCS#8 PEM is parsed into an `RsaSigningKey` lazily on first use
17/// and cached for the lifetime of this config — important on the hot
18/// outbound path where every delivered message triggers a sign call.
19/// Existing struct-literal callers need to add `..Default::default()`
20/// (the parsed-key cache is non-public, populated on demand).
21///
22/// **v3 (mailrs-dkim 3.0)**: `RsaSigningKey` wraps aws-lc-rs's
23/// `RsaKeyPair`, so per-sign cost dropped from ~1.5 ms (pure-Rust
24/// `rsa` crate) to ~0.5 ms.
25#[derive(Debug, Clone, Default)]
26pub struct DkimSignConfig {
27    /// DKIM selector — the label under `<selector>._domainkey.<domain>`.
28    pub selector: String,
29    /// Signing domain (matches the `d=` tag in the DKIM-Signature header).
30    pub domain: String,
31    /// Private RSA key in PKCS#8 PEM form.
32    pub private_key_pem: String,
33    /// Lazy-parsed `RsaSigningKey`, shared across clones so worker
34    /// concurrency doesn't re-parse per delivery thread.
35    ///
36    /// **Implementation detail** — leave at `Default::default()`. Pub
37    /// only so out-of-crate struct-literal callers can spread
38    /// `..Default::default()`. Reading or mutating this field
39    /// directly is not supported and the type may change.
40    #[doc(hidden)]
41    pub parsed_key: Arc<OnceLock<Result<RsaSigningKey, String>>>,
42}
43
44impl DkimSignConfig {
45    /// Return a borrowed handle to the parsed RSA key, parsing the PEM
46    /// once on first call and caching the result (success OR error) so
47    /// every later call is a pointer-load.
48    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    /// Sign a message, prepending the DKIM-Signature header.
59    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
85/// Extract domain from email address.
86pub fn extract_domain(email: &str) -> Option<&str> {
87    email.rsplit_once('@').map(|(_, domain)| domain)
88}
89
90/// ARC-seal a forwarded message, preserving the authentication chain
91/// (RFC 8617). Uses `mailrs-dkim` + `mailrs-arc` for verify-then-seal.
92pub async fn arc_seal_message(
93    dkim_config: &DkimSignConfig,
94    dkim_resolver: &HickoryDkimResolver,
95    message: &[u8],
96) -> Result<Vec<u8>, String> {
97    // 1. Verify existing DKIM signatures — used as our hop's authres body.
98    let dkim_outputs = verify_all(dkim_resolver, message).await;
99
100    // 2. Extract + verify the prior ARC chain (if any). `cv=` on the
101    //    new seal mirrors the verdict.
102    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    // 3. Build the AAR body — coarse DKIM verdict per signature, in
118    //    the same shape an `Authentication-Results` header would carry.
119    let authres = build_authres_body(&dkim_config.domain, &dkim_outputs);
120
121    // 4. Load the signing key and seal. Reuse the lazy-parsed cache
122    //    on DkimSignConfig so we don't re-parse PEM per outbound msg.
123    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}