Skip to main content

email_auth/dkim/
sign.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3use base64::engine::general_purpose::STANDARD as BASE64;
4use base64::Engine;
5use ring::rand::SystemRandom;
6use ring::signature as ring_sig;
7use ring::signature::KeyPair;
8
9use super::canon::{
10    canonicalize_body, canonicalize_header, normalize_line_endings, select_headers,
11};
12use super::types::{Algorithm, CanonicalizationMethod, HashAlgorithm};
13
14/// Error returned by DkimSigner construction or signing.
15#[derive(Debug)]
16pub struct SigningError {
17    pub detail: String,
18}
19
20impl std::fmt::Display for SigningError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_str(&self.detail)
23    }
24}
25
26impl std::error::Error for SigningError {}
27
28/// Private key holder — either RSA or Ed25519.
29enum PrivateKey {
30    Rsa(ring_sig::RsaKeyPair),
31    Ed25519(ring_sig::Ed25519KeyPair),
32}
33
34/// DKIM message signer.
35///
36/// Construct via `DkimSigner::rsa_sha256()` or `DkimSigner::ed25519()`.
37/// RSA-SHA1 signing is intentionally NOT supported (verify only).
38pub struct DkimSigner {
39    key: PrivateKey,
40    algorithm: Algorithm,
41    domain: String,
42    selector: String,
43    header_canon: CanonicalizationMethod,
44    body_canon: CanonicalizationMethod,
45    headers_to_sign: Vec<String>,
46    over_sign: bool,
47    expiration_seconds: Option<u64>,
48}
49
50impl DkimSigner {
51    /// Create an RSA-SHA256 signer from PKCS8-encoded PEM private key.
52    pub fn rsa_sha256(
53        domain: impl Into<String>,
54        selector: impl Into<String>,
55        pkcs8_pem: &[u8],
56    ) -> Result<Self, SigningError> {
57        let der = decode_pem(pkcs8_pem)?;
58        let key_pair = ring_sig::RsaKeyPair::from_pkcs8(&der)
59            .map_err(|e| SigningError { detail: format!("invalid RSA PKCS8 key: {}", e) })?;
60
61        Ok(Self {
62            key: PrivateKey::Rsa(key_pair),
63            algorithm: Algorithm::RsaSha256,
64            domain: domain.into(),
65            selector: selector.into(),
66            header_canon: CanonicalizationMethod::Relaxed,
67            body_canon: CanonicalizationMethod::Relaxed,
68            headers_to_sign: default_headers(),
69            over_sign: false,
70            expiration_seconds: None,
71        })
72    }
73
74    /// Create an Ed25519-SHA256 signer from PKCS8-encoded key bytes.
75    ///
76    /// IMPORTANT: ring 0.17 rejects OpenSSL-generated Ed25519 PKCS8 keys.
77    /// Generate keys with `ring::signature::Ed25519KeyPair::generate_pkcs8()`.
78    pub fn ed25519(
79        domain: impl Into<String>,
80        selector: impl Into<String>,
81        pkcs8: &[u8],
82    ) -> Result<Self, SigningError> {
83        let key_pair = ring_sig::Ed25519KeyPair::from_pkcs8(pkcs8)
84            .map_err(|e| SigningError { detail: format!("invalid Ed25519 PKCS8 key: {}", e) })?;
85
86        Ok(Self {
87            key: PrivateKey::Ed25519(key_pair),
88            algorithm: Algorithm::Ed25519Sha256,
89            domain: domain.into(),
90            selector: selector.into(),
91            header_canon: CanonicalizationMethod::Relaxed,
92            body_canon: CanonicalizationMethod::Relaxed,
93            headers_to_sign: default_headers(),
94            over_sign: false,
95            expiration_seconds: None,
96        })
97    }
98
99    /// Set header canonicalization method.
100    pub fn header_canonicalization(mut self, method: CanonicalizationMethod) -> Self {
101        self.header_canon = method;
102        self
103    }
104
105    /// Set body canonicalization method.
106    pub fn body_canonicalization(mut self, method: CanonicalizationMethod) -> Self {
107        self.body_canon = method;
108        self
109    }
110
111    /// Set specific headers to sign (replaces defaults).
112    pub fn headers(mut self, headers: Vec<String>) -> Self {
113        self.headers_to_sign = headers;
114        self
115    }
116
117    /// Enable over-signing: each header name appears twice in h= to prevent injection.
118    pub fn over_sign(mut self, enabled: bool) -> Self {
119        self.over_sign = enabled;
120        self
121    }
122
123    /// Set signature expiration (seconds from signing time).
124    pub fn expiration(mut self, seconds: u64) -> Self {
125        self.expiration_seconds = Some(seconds);
126        self
127    }
128
129    /// Sign a message and return the DKIM-Signature header value.
130    ///
131    /// `headers`: message headers as `(name, value)` pairs.
132    /// `body`: raw message body bytes.
133    ///
134    /// Returns the complete header value (everything after "DKIM-Signature:").
135    pub fn sign_message(
136        &self,
137        headers: &[(&str, &str)],
138        body: &[u8],
139    ) -> Result<String, SigningError> {
140        // Validate From is in headers_to_sign
141        if !self.headers_to_sign.iter().any(|h| h.eq_ignore_ascii_case("from")) {
142            return Err(SigningError {
143                detail: "h= headers must include 'from'".into(),
144            });
145        }
146
147        // Step 1: Canonicalize body → hash → bh=
148        let normalized_body = normalize_line_endings(body);
149        let canon_body = canonicalize_body(self.body_canon, &normalized_body);
150        let body_hash = compute_hash(self.algorithm, &canon_body);
151        let bh_b64 = BASE64.encode(&body_hash);
152
153        // Step 2: Build h= value (with over-signing if enabled)
154        let h_value = self.build_h_value();
155
156        // Step 3: Timestamps
157        let now = SystemTime::now()
158            .duration_since(UNIX_EPOCH)
159            .map(|d| d.as_secs())
160            .unwrap_or(0);
161
162        // Step 4: Build DKIM-Signature template with b= empty
163        let algo_str = match self.algorithm {
164            Algorithm::RsaSha256 => "rsa-sha256",
165            Algorithm::Ed25519Sha256 => "ed25519-sha256",
166            Algorithm::RsaSha1 => unreachable!("RSA-SHA1 signing not supported"),
167        };
168        let canon_str = format!(
169            "{}/{}",
170            canon_method_str(self.header_canon),
171            canon_method_str(self.body_canon),
172        );
173
174        let mut sig_template = format!(
175            " v=1; a={}; c={}; d={}; s={}; t={}; h={}; bh={}; b=",
176            algo_str, canon_str, self.domain, self.selector, now, h_value, bh_b64,
177        );
178
179        if let Some(exp_secs) = self.expiration_seconds {
180            // Insert x= before h=
181            let x_val = now + exp_secs;
182            sig_template = format!(
183                " v=1; a={}; c={}; d={}; s={}; t={}; x={}; h={}; bh={}; b=",
184                algo_str, canon_str, self.domain, self.selector, now, x_val, h_value, bh_b64,
185            );
186        }
187
188        // Step 5: Compute header hash input
189        // Select headers using h= list (the actual header names, not over-signed duplicates)
190        let h_names: Vec<String> = self.build_h_names();
191
192        let selected = select_headers(self.header_canon, &h_names, headers);
193
194        let mut hash_input = Vec::new();
195        for header_line in &selected {
196            hash_input.extend_from_slice(header_line.as_bytes());
197        }
198
199        // Append DKIM-Signature template with b= empty, canonicalized, NO trailing CRLF
200        let canon_sig = if self.header_canon == CanonicalizationMethod::Simple {
201            format!("DKIM-Signature:{}", sig_template)
202        } else {
203            canonicalize_header(self.header_canon, "dkim-signature", &sig_template)
204        };
205        hash_input.extend_from_slice(canon_sig.as_bytes());
206
207        // Step 6: Sign the hash input
208        let signature = self.sign_raw(&hash_input)?;
209        let sig_b64 = BASE64.encode(&signature);
210
211        // Step 7: Fill in b= value
212        let full_sig = format!("{}{}", sig_template, sig_b64);
213
214        Ok(full_sig)
215    }
216
217    /// Build the h= value string for the DKIM-Signature header.
218    fn build_h_value(&self) -> String {
219        let mut names = Vec::new();
220        for h in &self.headers_to_sign {
221            names.push(h.to_lowercase());
222            if self.over_sign {
223                names.push(h.to_lowercase());
224            }
225        }
226        names.join(":")
227    }
228
229    /// Build the h= names list for header selection (including over-sign duplicates).
230    fn build_h_names(&self) -> Vec<String> {
231        let mut names = Vec::new();
232        for h in &self.headers_to_sign {
233            names.push(h.to_lowercase());
234            if self.over_sign {
235                names.push(h.to_lowercase());
236            }
237        }
238        names
239    }
240
241    /// Sign raw data with the private key.
242    fn sign_raw(&self, data: &[u8]) -> Result<Vec<u8>, SigningError> {
243        match &self.key {
244            PrivateKey::Rsa(key_pair) => {
245                let rng = SystemRandom::new();
246                let mut signature = vec![0u8; key_pair.public().modulus_len()];
247                key_pair
248                    .sign(&ring_sig::RSA_PKCS1_SHA256, &rng, data, &mut signature)
249                    .map_err(|e| SigningError { detail: format!("RSA signing failed: {}", e) })?;
250                Ok(signature)
251            }
252            PrivateKey::Ed25519(key_pair) => {
253                let sig = key_pair.sign(data);
254                Ok(sig.as_ref().to_vec())
255            }
256        }
257    }
258
259    /// Get the public key bytes for DNS record generation (test utility).
260    /// For Ed25519: raw 32-byte public key.
261    /// For RSA: SPKI DER encoded public key (wraps PKCS#1 from ring).
262    pub fn public_key_bytes(&self) -> Vec<u8> {
263        match &self.key {
264            PrivateKey::Rsa(key_pair) => {
265                // ring's public() returns PKCS#1 RSAPublicKey DER.
266                // DKIM p= expects SPKI format. Wrap it.
267                let pkcs1_der = key_pair.public().as_ref();
268                wrap_pkcs1_in_spki(pkcs1_der)
269            }
270            PrivateKey::Ed25519(key_pair) => {
271                key_pair.public_key().as_ref().to_vec()
272            }
273        }
274    }
275}
276
277/// Wrap PKCS#1 RSAPublicKey DER in SubjectPublicKeyInfo (SPKI) DER.
278/// SPKI = SEQUENCE { AlgorithmIdentifier, BIT STRING { PKCS#1 } }
279/// AlgorithmIdentifier = SEQUENCE { OID(rsaEncryption), NULL }
280fn wrap_pkcs1_in_spki(pkcs1_der: &[u8]) -> Vec<u8> {
281    // RSA AlgorithmIdentifier: SEQUENCE { OID 1.2.840.113549.1.1.1, NULL }
282    let algo_id: &[u8] = &[
283        0x30, 0x0d, // SEQUENCE, 13 bytes
284        0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, // OID
285        0x05, 0x00, // NULL
286    ];
287
288    // BIT STRING wrapping: 0x03 + length + 0x00 (unused bits) + content
289    let bit_string_content_len = 1 + pkcs1_der.len(); // 0x00 byte + PKCS#1
290    let mut bit_string = vec![0x03];
291    encode_asn1_length(&mut bit_string, bit_string_content_len);
292    bit_string.push(0x00); // unused bits = 0
293    bit_string.extend_from_slice(pkcs1_der);
294
295    // Outer SEQUENCE
296    let inner_len = algo_id.len() + bit_string.len();
297    let mut spki = vec![0x30];
298    encode_asn1_length(&mut spki, inner_len);
299    spki.extend_from_slice(algo_id);
300    spki.extend_from_slice(&bit_string);
301
302    spki
303}
304
305/// Encode ASN.1 DER length.
306fn encode_asn1_length(output: &mut Vec<u8>, len: usize) {
307    if len < 0x80 {
308        output.push(len as u8);
309    } else if len < 0x100 {
310        output.push(0x81);
311        output.push(len as u8);
312    } else {
313        output.push(0x82);
314        output.push((len >> 8) as u8);
315        output.push((len & 0xff) as u8);
316    }
317}
318
319/// Default headers to sign per RFC 6376 §5.4.
320fn default_headers() -> Vec<String> {
321    vec![
322        "from".into(),
323        "to".into(),
324        "subject".into(),
325        "date".into(),
326        "mime-version".into(),
327        "content-type".into(),
328        "message-id".into(),
329    ]
330}
331
332fn canon_method_str(m: CanonicalizationMethod) -> &'static str {
333    match m {
334        CanonicalizationMethod::Simple => "simple",
335        CanonicalizationMethod::Relaxed => "relaxed",
336    }
337}
338
339/// Compute hash using the algorithm's hash function.
340fn compute_hash(algorithm: Algorithm, data: &[u8]) -> Vec<u8> {
341    match algorithm.hash_algorithm() {
342        HashAlgorithm::Sha256 => {
343            let digest = ring::digest::digest(&ring::digest::SHA256, data);
344            digest.as_ref().to_vec()
345        }
346        HashAlgorithm::Sha1 => {
347            let digest = ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, data);
348            digest.as_ref().to_vec()
349        }
350    }
351}
352
353/// Decode PEM to DER. Handles "BEGIN PRIVATE KEY" or "BEGIN RSA PRIVATE KEY".
354fn decode_pem(pem: &[u8]) -> Result<Vec<u8>, SigningError> {
355    let pem_str = std::str::from_utf8(pem)
356        .map_err(|_| SigningError { detail: "PEM is not valid UTF-8".into() })?;
357
358    // Find base64 content between BEGIN/END markers
359    let lines: Vec<&str> = pem_str.lines().collect();
360    let mut b64 = String::new();
361    let mut in_block = false;
362
363    for line in &lines {
364        let trimmed = line.trim();
365        if trimmed.starts_with("-----BEGIN") {
366            in_block = true;
367            continue;
368        }
369        if trimmed.starts_with("-----END") {
370            break;
371        }
372        if in_block {
373            b64.push_str(trimmed);
374        }
375    }
376
377    if b64.is_empty() {
378        return Err(SigningError { detail: "no PEM content found".into() });
379    }
380
381    BASE64.decode(&b64)
382        .map_err(|e| SigningError { detail: format!("PEM base64 decode failed: {}", e) })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::common::dns::mock::MockResolver;
389    use crate::dkim::verify::DkimVerifier;
390    use crate::dkim::DkimResult;
391
392    fn setup_mock_key(resolver: &mut MockResolver, selector: &str, domain: &str, txt: &str) {
393        let qname = format!("{}._domainkey.{}", selector, domain);
394        resolver.add_txt(&qname, vec![txt.to_string()]);
395    }
396
397    // ─── CHK-424: Key loading ────────────────────────────────────────
398
399    #[test]
400    fn ed25519_key_loads_from_pkcs8() {
401        let rng = SystemRandom::new();
402        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
403        let signer = DkimSigner::ed25519("example.com", "sel", pkcs8.as_ref());
404        assert!(signer.is_ok());
405    }
406
407    #[test]
408    fn rsa_key_loads_from_pem() {
409        let pem = generate_rsa_pem();
410        let signer = DkimSigner::rsa_sha256("example.com", "sel", &pem);
411        assert!(signer.is_ok());
412    }
413
414    #[test]
415    fn invalid_key_fails_fast() {
416        let result = DkimSigner::ed25519("example.com", "sel", b"not-a-key");
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn invalid_pem_fails() {
422        let result = DkimSigner::rsa_sha256("example.com", "sel", b"not-pem");
423        assert!(result.is_err());
424    }
425
426    // ─── CHK-508: RSA-SHA1 signing prevention ────────────────────────
427
428    #[test]
429    fn rsa_sha1_signing_not_constructable() {
430        // DkimSigner has no constructor for RSA-SHA1.
431        // The only constructors are rsa_sha256() and ed25519().
432        // This test verifies no code path can produce a=rsa-sha1 in a signature.
433        let pem = generate_rsa_pem();
434        let signer = DkimSigner::rsa_sha256("example.com", "sel", &pem).unwrap();
435        assert!(matches!(signer.algorithm, Algorithm::RsaSha256));
436        // Algorithm::RsaSha1 has no corresponding signer constructor
437    }
438
439    // ─── CHK-426: From header enforced ───────────────────────────────
440
441    #[test]
442    fn sign_without_from_fails() {
443        let rng = SystemRandom::new();
444        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
445        let signer = DkimSigner::ed25519("example.com", "sel", pkcs8.as_ref())
446            .unwrap()
447            .headers(vec!["to".into(), "subject".into()]); // no "from"
448
449        let headers = vec![
450            ("From", " user@example.com"),
451            ("To", " other@example.com"),
452        ];
453        let result = signer.sign_message(&headers, b"body\r\n");
454        assert!(result.is_err());
455        assert!(result.unwrap_err().detail.contains("from"));
456    }
457
458    // ─── CHK-502: Ed25519 sign-then-verify roundtrip ─────────────────
459
460    #[tokio::test]
461    async fn ed25519_sign_verify_roundtrip() {
462        let rng = SystemRandom::new();
463        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
464        let signer = DkimSigner::ed25519("example.com", "ed", pkcs8.as_ref()).unwrap();
465
466        let headers = vec![
467            ("From", " user@example.com"),
468            ("To", " other@example.com"),
469            ("Subject", " Test message"),
470        ];
471        let body = b"Hello world\r\n";
472
473        let sig_value = signer.sign_message(&headers, body).unwrap();
474
475        // Set up verifier with mock DNS
476        let pub_key = signer.public_key_bytes();
477        let pub_b64 = BASE64.encode(&pub_key);
478
479        let mut resolver = MockResolver::new();
480        setup_mock_key(
481            &mut resolver,
482            "ed",
483            "example.com",
484            &format!("v=DKIM1; k=ed25519; p={}", pub_b64),
485        );
486
487        let verifier = DkimVerifier::new(resolver);
488        let mut all_headers = headers.clone();
489        all_headers.push(("DKIM-Signature", &sig_value));
490
491        let results = verifier.verify_message(&all_headers, body).await;
492        match &results[0] {
493            DkimResult::Pass { domain, .. } => assert_eq!(domain, "example.com"),
494            other => panic!("Ed25519 roundtrip: expected Pass, got {:?}", other),
495        }
496    }
497
498    // ─── CHK-503: RSA-SHA256 sign-then-verify roundtrip ──────────────
499
500    #[tokio::test]
501    async fn rsa_sha256_sign_verify_roundtrip() {
502        let pem = generate_rsa_pem();
503        let signer = DkimSigner::rsa_sha256("example.com", "rsa", &pem).unwrap();
504
505        let headers = vec![
506            ("From", " sender@example.com"),
507            ("To", " recipient@example.com"),
508            ("Subject", " RSA roundtrip test"),
509        ];
510        let body = b"RSA signed body\r\n";
511
512        let sig_value = signer.sign_message(&headers, body).unwrap();
513
514        // Get SPKI public key for DNS
515        let pub_key = signer.public_key_bytes();
516        let pub_b64 = BASE64.encode(&pub_key);
517
518        let mut resolver = MockResolver::new();
519        setup_mock_key(
520            &mut resolver,
521            "rsa",
522            "example.com",
523            &format!("v=DKIM1; k=rsa; p={}", pub_b64),
524        );
525
526        let verifier = DkimVerifier::new(resolver);
527        let mut all_headers = headers.clone();
528        all_headers.push(("DKIM-Signature", &sig_value));
529
530        let results = verifier.verify_message(&all_headers, body).await;
531        match &results[0] {
532            DkimResult::Pass { domain, .. } => assert_eq!(domain, "example.com"),
533            other => panic!("RSA-SHA256 roundtrip: expected Pass, got {:?}", other),
534        }
535    }
536
537    // ─── CHK-504: Different canonicalization modes ───────────────────
538
539    #[tokio::test]
540    async fn simple_simple_roundtrip() {
541        let rng = SystemRandom::new();
542        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
543        let signer = DkimSigner::ed25519("example.com", "ed", pkcs8.as_ref())
544            .unwrap()
545            .header_canonicalization(CanonicalizationMethod::Simple)
546            .body_canonicalization(CanonicalizationMethod::Simple);
547
548        let headers = vec![
549            ("From", " user@example.com"),
550            ("Subject", " Simple test"),
551        ];
552        let body = b"Simple body\r\n";
553
554        let sig_value = signer.sign_message(&headers, body).unwrap();
555        assert!(sig_value.contains("c=simple/simple"));
556
557        let pub_key = signer.public_key_bytes();
558        let pub_b64 = BASE64.encode(&pub_key);
559
560        let mut resolver = MockResolver::new();
561        setup_mock_key(
562            &mut resolver,
563            "ed",
564            "example.com",
565            &format!("v=DKIM1; k=ed25519; p={}", pub_b64),
566        );
567
568        let verifier = DkimVerifier::new(resolver);
569        let mut all_headers = headers.clone();
570        all_headers.push(("DKIM-Signature", &sig_value));
571
572        let results = verifier.verify_message(&all_headers, body).await;
573        match &results[0] {
574            DkimResult::Pass { .. } => {}
575            other => panic!("simple/simple roundtrip: expected Pass, got {:?}", other),
576        }
577    }
578
579    // ─── CHK-506: Timestamp and expiration ───────────────────────────
580
581    #[tokio::test]
582    async fn timestamp_and_expiration_set() {
583        let rng = SystemRandom::new();
584        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
585        let signer = DkimSigner::ed25519("example.com", "ed", pkcs8.as_ref())
586            .unwrap()
587            .expiration(3600);
588
589        let headers = vec![("From", " user@example.com")];
590        let body = b"body\r\n";
591
592        let sig_value = signer.sign_message(&headers, body).unwrap();
593        assert!(sig_value.contains("t="));
594        assert!(sig_value.contains("x="));
595
596        // Verify the expiration is t + 3600
597        let t_pos = sig_value.find("t=").unwrap() + 2;
598        let t_end = sig_value[t_pos..].find(';').unwrap() + t_pos;
599        let t: u64 = sig_value[t_pos..t_end].trim().parse().unwrap();
600
601        let x_pos = sig_value.find("x=").unwrap() + 2;
602        let x_end = sig_value[x_pos..].find(';').unwrap() + x_pos;
603        let x: u64 = sig_value[x_pos..x_end].trim().parse().unwrap();
604
605        assert_eq!(x, t + 3600);
606    }
607
608    // ─── CHK-507: PEM key loading ────────────────────────────────────
609
610    #[test]
611    fn pem_decode_rsa_key() {
612        let pem = generate_rsa_pem();
613        let result = decode_pem(&pem);
614        assert!(result.is_ok());
615    }
616
617    #[test]
618    fn pem_decode_invalid_base64() {
619        let bad_pem = b"-----BEGIN PRIVATE KEY-----\n!!!invalid!!!\n-----END PRIVATE KEY-----\n";
620        let result = decode_pem(bad_pem);
621        assert!(result.is_err());
622    }
623
624    #[test]
625    fn pem_decode_empty() {
626        let result = decode_pem(b"no markers here");
627        assert!(result.is_err());
628    }
629
630    // ─── CHK-509: Over-sign roundtrip ────────────────────────────────
631
632    #[tokio::test]
633    async fn over_sign_roundtrip() {
634        let rng = SystemRandom::new();
635        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
636        let signer = DkimSigner::ed25519("example.com", "ed", pkcs8.as_ref())
637            .unwrap()
638            .over_sign(true);
639
640        let headers = vec![
641            ("From", " user@example.com"),
642            ("To", " other@example.com"),
643            ("Subject", " Over-sign test"),
644        ];
645        let body = b"Over-sign body\r\n";
646
647        let sig_value = signer.sign_message(&headers, body).unwrap();
648        // Each header name should appear twice in h=
649        assert!(sig_value.contains("h=from:from:to:to:subject:subject"));
650
651        let pub_key = signer.public_key_bytes();
652        let pub_b64 = BASE64.encode(&pub_key);
653
654        let mut resolver = MockResolver::new();
655        setup_mock_key(
656            &mut resolver,
657            "ed",
658            "example.com",
659            &format!("v=DKIM1; k=ed25519; p={}", pub_b64),
660        );
661
662        let verifier = DkimVerifier::new(resolver);
663        let mut all_headers = headers.clone();
664        all_headers.push(("DKIM-Signature", &sig_value));
665
666        let results = verifier.verify_message(&all_headers, body).await;
667        match &results[0] {
668            DkimResult::Pass { .. } => {}
669            other => panic!("over-sign roundtrip: expected Pass, got {:?}", other),
670        }
671    }
672
673    // ─── CHK-433: Ground-truth bypass test ───────────────────────────
674    // Already covered by lane 7 verify.rs ground-truth tests that construct
675    // signatures manually with ring primitives and verify through DkimVerifier.
676    // This is the complementary direction: sign with DkimSigner and verify
677    // the output matches expected format.
678
679    #[test]
680    fn signed_header_contains_required_tags() {
681        let rng = SystemRandom::new();
682        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
683        let signer = DkimSigner::ed25519("example.com", "sel", pkcs8.as_ref()).unwrap();
684
685        let headers = vec![("From", " user@example.com")];
686        let sig = signer.sign_message(&headers, b"body\r\n").unwrap();
687
688        assert!(sig.contains("v=1"));
689        assert!(sig.contains("a=ed25519-sha256"));
690        assert!(sig.contains("d=example.com"));
691        assert!(sig.contains("s=sel"));
692        assert!(sig.contains("h="));
693        assert!(sig.contains("bh="));
694        assert!(sig.contains("b="));
695        assert!(sig.contains("t="));
696    }
697
698    // ─── CHK-427/428: Recommended + avoided headers ──────────────────
699
700    #[test]
701    fn default_headers_include_recommended() {
702        let defaults = default_headers();
703        assert!(defaults.contains(&"from".to_string()));
704        assert!(defaults.contains(&"to".to_string()));
705        assert!(defaults.contains(&"subject".to_string()));
706        assert!(defaults.contains(&"date".to_string()));
707        assert!(defaults.contains(&"message-id".to_string()));
708        // Should NOT include transit headers
709        assert!(!defaults.contains(&"received".to_string()));
710        assert!(!defaults.contains(&"return-path".to_string()));
711    }
712
713    // ─── CHK-430: Timestamp set ──────────────────────────────────────
714
715    #[test]
716    fn signature_has_timestamp() {
717        let rng = SystemRandom::new();
718        let pkcs8 = ring_sig::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
719        let signer = DkimSigner::ed25519("example.com", "sel", pkcs8.as_ref()).unwrap();
720
721        let headers = vec![("From", " user@example.com")];
722        let sig = signer.sign_message(&headers, b"body\r\n").unwrap();
723        assert!(sig.contains("t="));
724
725        // Parse timestamp, verify it's recent
726        let t_pos = sig.find("t=").unwrap() + 2;
727        let t_end = sig[t_pos..].find(';').unwrap() + t_pos;
728        let t: u64 = sig[t_pos..t_end].trim().parse().unwrap();
729        let now = SystemTime::now()
730            .duration_since(UNIX_EPOCH)
731            .unwrap()
732            .as_secs();
733        assert!(t <= now);
734        assert!(t > now - 10); // within last 10 seconds
735    }
736
737    // ─── Helper: Generate RSA key for tests ──────────────────────────
738
739    fn generate_rsa_pem() -> Vec<u8> {
740        // Use openssl to generate a 2048-bit RSA PKCS8 PEM key
741        // Since ring doesn't expose RSA key generation, we use a pre-generated key
742        // or generate one with ring's internal methods.
743        // Actually, ring DOES have RsaKeyPair but no key generation.
744        // For tests, embed a test-only RSA key.
745        include_bytes!("../../tests/fixtures/rsa2048.pem").to_vec()
746    }
747}