Skip to main content

mailrs_dkim/
header.rs

1//! DKIM-Signature header parsing (RFC 6376 §3.5).
2
3use crate::error::DkimError;
4
5/// Algorithm announced in the `a=` tag.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Algorithm {
8    /// `a=rsa-sha256` — RSA over SHA-256. ~99% of real-world DKIM.
9    RsaSha256,
10    /// `a=ed25519-sha256` — Ed25519 over SHA-256, per RFC 8463.
11    /// Modern but rare; ~1% of real-world DKIM in 2026.
12    Ed25519Sha256,
13}
14
15/// Canonicalization variant.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Canon {
18    /// `simple` — body: must end with one CRLF, ignore trailing
19    /// empty lines; headers: untouched (whitespace preserved verbatim).
20    Simple,
21    /// `relaxed` — body: collapse internal WSP runs to one SP,
22    /// strip trailing WSP, then apply simple; headers: lowercase
23    /// name, unfold, collapse WSP, strip trailing WSP after value.
24    Relaxed,
25}
26
27/// Parsed DKIM-Signature header. Borrows the `b=` (signature) and
28/// `bh=` (body hash) base64 strings + the signed-header list etc.
29/// Owned `String` is used for tag values that we may need to
30/// case-fold or massage during verify (e.g. signed-header names get
31/// lowercased for relaxed canon).
32#[derive(Debug, Clone)]
33pub struct DkimHeader {
34    /// `v=` — version (must be "1" per RFC 6376).
35    pub version: u32,
36    /// `a=` — signature algorithm.
37    pub algorithm: Algorithm,
38    /// `b=` — base64-encoded signature bytes.
39    pub signature_b64: String,
40    /// `bh=` — base64-encoded body hash.
41    pub body_hash_b64: String,
42    /// `c=` — `(header_canon, body_canon)` tuple. Default
43    /// `(Simple, Simple)` per spec.
44    pub canon_header: Canon,
45    /// see [`Self::canon_header`].
46    pub canon_body: Canon,
47    /// `d=` — signing domain (used in the selector DNS lookup).
48    pub domain: String,
49    /// `s=` — selector (used in `<s>._domainkey.<d>` TXT lookup).
50    pub selector: String,
51    /// `h=` — colon-separated list of signed header names, in the
52    /// order they were signed. **Lowercased and trimmed** in
53    /// parse so verifier doesn't have to.
54    pub signed_headers: Vec<String>,
55    /// `l=` — optional body length limit. Some signers sign only the
56    /// first N bytes of the body to allow trailing additions.
57    pub body_length: Option<u64>,
58    /// `t=` — optional signature timestamp (seconds since epoch).
59    pub timestamp: Option<u64>,
60    /// `x=` — optional expiry (seconds since epoch). Verifier checks
61    /// `now > x` → expired.
62    pub expiration: Option<u64>,
63    /// `i=` — optional identity (used for DMARC alignment but not
64    /// for hash inputs).
65    pub identity: Option<String>,
66    /// `q=` — query method (default "dns/txt"). We only support
67    /// "dns/txt"; anything else → UnsupportedAlgorithm.
68    pub query_method: String,
69}
70
71impl DkimHeader {
72    /// Parse a single `DKIM-Signature:` header value. Caller has already
73    /// stripped the `DKIM-Signature:` prefix; this function expects the
74    /// VALUE portion (everything after the first `:`).
75    ///
76    /// The header may contain folded continuation lines (CRLF + WSP);
77    /// we unfold internally before parsing tags.
78    pub fn parse(value: &str) -> Result<Self, DkimError> {
79        // Single-pass byte-level scan. No HashMap, no unfold pre-allocation.
80        // Tag dispatch is a string match against the small known set; CRLF+WSP
81        // folding is consumed inline as whitespace inside values.
82        let bytes = value.as_bytes();
83        let n = bytes.len();
84        let mut i = 0;
85
86        let mut version: Option<u32> = None;
87        let mut algorithm: Option<Algorithm> = None;
88        let mut signature_b64: Option<String> = None;
89        let mut body_hash_b64: Option<String> = None;
90        let mut canon_header = Canon::Simple;
91        let mut canon_body = Canon::Simple;
92        let mut domain: Option<String> = None;
93        let mut selector: Option<String> = None;
94        let mut signed_headers: Option<Vec<String>> = None;
95        let mut body_length: Option<u64> = None;
96        let mut timestamp: Option<u64> = None;
97        let mut expiration: Option<u64> = None;
98        let mut identity: Option<String> = None;
99        let mut query_method: Option<String> = None;
100
101        while i < n {
102            // Skip separators / whitespace / folding between tags.
103            while i < n && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n' | b';') {
104                i += 1;
105            }
106            if i >= n {
107                break;
108            }
109
110            // Tag name: ASCII until '=' or whitespace.
111            let name_start = i;
112            while i < n && !matches!(bytes[i], b'=' | b' ' | b'\t' | b'\r' | b'\n' | b';') {
113                i += 1;
114            }
115            let name = &value[name_start..i];
116            if name.is_empty() {
117                return Err(DkimError::InvalidTag(format!(
118                    "no tag name at offset {name_start}"
119                )));
120            }
121
122            // Allow optional WSP before '='.
123            while i < n && matches!(bytes[i], b' ' | b'\t') {
124                i += 1;
125            }
126            if i >= n || bytes[i] != b'=' {
127                return Err(DkimError::InvalidTag(format!(
128                    "no `=` after tag {name:?}"
129                )));
130            }
131            i += 1;
132
133            // Tag value: everything up to the next ';' that's not inside
134            // folded whitespace. CRLF+WSP inside the value is preserved here;
135            // tag-specific handling strips it (b/bh) or trims it (others).
136            let val_start = i;
137            while i < n && bytes[i] != b';' {
138                i += 1;
139            }
140            let raw_val = &value[val_start..i];
141
142            // Tag dispatch. Lowercase byte-match is the hot path; real-world
143            // DKIM headers always use lowercase tag names (RFC 6376 §3.2
144            // says case-insensitive but every signer in the wild emits
145            // lowercase). For correctness with mixed-case tags we fall
146            // through to a case-insensitive comparison after the byte match.
147            let name_bytes = name.as_bytes();
148            match name_bytes {
149                b"v" => {
150                    let trimmed = raw_val.trim();
151                    let parsed: u32 = trimmed
152                        .parse()
153                        .map_err(|_| DkimError::InvalidTag(format!("v={trimmed}")))?;
154                    if parsed != 1 {
155                        return Err(DkimError::InvalidTag(format!(
156                            "v={parsed}, expected 1"
157                        )));
158                    }
159                    version = Some(parsed);
160                }
161                b"a" => {
162                    algorithm = Some(match raw_val.trim() {
163                        "rsa-sha256" => Algorithm::RsaSha256,
164                        "ed25519-sha256" => Algorithm::Ed25519Sha256,
165                        other => return Err(DkimError::UnsupportedAlgorithm(other.to_string())),
166                    });
167                }
168                b"b" => signature_b64 = Some(strip_wsp(raw_val)),
169                b"bh" => body_hash_b64 = Some(strip_wsp(raw_val)),
170                b"d" => domain = Some(raw_val.trim().to_string()),
171                b"s" => selector = Some(raw_val.trim().to_string()),
172                b"h" => {
173                    // Byte-level scan. The realistic case carries 7+ signed
174                    // headers; using `split(':').map(to_ascii_lowercase)`
175                    // does double work (split walks chars, then each
176                    // to_ascii_lowercase walks chars again allocating a new
177                    // String). Doing both in one byte-iteration shaves
178                    // ~50 ns on the realistic case.
179                    let bytes = raw_val.as_bytes();
180                    let mut list: Vec<String> = Vec::with_capacity(8);
181                    let mut cur: Vec<u8> = Vec::with_capacity(20);
182                    for &b in bytes {
183                        match b {
184                            b' ' | b'\t' | b'\r' | b'\n' => {} // skip wsp
185                            b':' => {
186                                if !cur.is_empty() {
187                                    // SAFETY: we only push ASCII-lowercased
188                                    // bytes (a..z, 0..9, '-') below, never
189                                    // anything outside the ASCII range, so
190                                    // the buffer is valid UTF-8 by
191                                    // construction.
192                                    let s = unsafe {
193                                        String::from_utf8_unchecked(std::mem::take(&mut cur))
194                                    };
195                                    list.push(s);
196                                    cur.reserve(20);
197                                }
198                            }
199                            _ => cur.push(b.to_ascii_lowercase()),
200                        }
201                    }
202                    if !cur.is_empty() {
203                        // SAFETY: see above — only ASCII bytes pushed.
204                        let s = unsafe { String::from_utf8_unchecked(cur) };
205                        list.push(s);
206                    }
207                    if list.is_empty() {
208                        return Err(DkimError::InvalidTag("h= empty".into()));
209                    }
210                    signed_headers = Some(list);
211                }
212                b"c" => {
213                    let (h, b) = parse_canon(raw_val)?;
214                    canon_header = h;
215                    canon_body = b;
216                }
217                b"l" => {
218                    let trimmed = raw_val.trim();
219                    body_length = Some(
220                        trimmed
221                            .parse()
222                            .map_err(|_| DkimError::InvalidTag(format!("l={trimmed}")))?,
223                    );
224                }
225                b"t" => {
226                    let trimmed = raw_val.trim();
227                    timestamp = Some(
228                        trimmed
229                            .parse()
230                            .map_err(|_| DkimError::InvalidTag(format!("t={trimmed}")))?,
231                    );
232                }
233                b"x" => {
234                    let trimmed = raw_val.trim();
235                    expiration = Some(
236                        trimmed
237                            .parse()
238                            .map_err(|_| DkimError::InvalidTag(format!("x={trimmed}")))?,
239                    );
240                }
241                b"i" => identity = Some(raw_val.trim().to_string()),
242                b"q" => query_method = Some(raw_val.trim().to_string()),
243                _ => {
244                    // Cold path: mixed-case or unknown tag name. Try
245                    // case-insensitive once before treating as unknown.
246                    if name.eq_ignore_ascii_case("v")
247                        || name.eq_ignore_ascii_case("a")
248                        || name.eq_ignore_ascii_case("b")
249                        || name.eq_ignore_ascii_case("bh")
250                        || name.eq_ignore_ascii_case("d")
251                        || name.eq_ignore_ascii_case("s")
252                        || name.eq_ignore_ascii_case("h")
253                        || name.eq_ignore_ascii_case("c")
254                        || name.eq_ignore_ascii_case("l")
255                        || name.eq_ignore_ascii_case("t")
256                        || name.eq_ignore_ascii_case("x")
257                        || name.eq_ignore_ascii_case("i")
258                        || name.eq_ignore_ascii_case("q")
259                    {
260                        // Retry with the lowercased name; we expect this
261                        // to be rare so allocation cost is acceptable.
262                        let lower = name.to_ascii_lowercase();
263                        match lower.as_bytes() {
264                            b"v" => {
265                                let trimmed = raw_val.trim();
266                                let parsed: u32 = trimmed
267                                    .parse()
268                                    .map_err(|_| DkimError::InvalidTag(format!("v={trimmed}")))?;
269                                if parsed != 1 {
270                                    return Err(DkimError::InvalidTag(format!(
271                                        "v={parsed}, expected 1"
272                                    )));
273                                }
274                                version = Some(parsed);
275                            }
276                            b"a" => {
277                                algorithm = Some(match raw_val.trim() {
278                                    "rsa-sha256" => Algorithm::RsaSha256,
279                                    "ed25519-sha256" => Algorithm::Ed25519Sha256,
280                                    other => {
281                                        return Err(DkimError::UnsupportedAlgorithm(
282                                            other.to_string(),
283                                        ));
284                                    }
285                                });
286                            }
287                            b"b" => signature_b64 = Some(strip_wsp(raw_val)),
288                            b"bh" => body_hash_b64 = Some(strip_wsp(raw_val)),
289                            b"d" => domain = Some(raw_val.trim().to_string()),
290                            b"s" => selector = Some(raw_val.trim().to_string()),
291                            b"h" => {
292                                let list: Vec<String> = raw_val
293                                    .split(':')
294                                    .map(|s| s.trim().to_ascii_lowercase())
295                                    .filter(|s| !s.is_empty())
296                                    .collect();
297                                if list.is_empty() {
298                                    return Err(DkimError::InvalidTag("h= empty".into()));
299                                }
300                                signed_headers = Some(list);
301                            }
302                            b"c" => {
303                                let (h, b) = parse_canon(raw_val)?;
304                                canon_header = h;
305                                canon_body = b;
306                            }
307                            b"l" => {
308                                let trimmed = raw_val.trim();
309                                body_length = Some(
310                                    trimmed
311                                        .parse()
312                                        .map_err(|_| DkimError::InvalidTag(format!("l={trimmed}")))?,
313                                );
314                            }
315                            b"t" => {
316                                let trimmed = raw_val.trim();
317                                timestamp = Some(
318                                    trimmed
319                                        .parse()
320                                        .map_err(|_| DkimError::InvalidTag(format!("t={trimmed}")))?,
321                                );
322                            }
323                            b"x" => {
324                                let trimmed = raw_val.trim();
325                                expiration = Some(
326                                    trimmed
327                                        .parse()
328                                        .map_err(|_| DkimError::InvalidTag(format!("x={trimmed}")))?,
329                                );
330                            }
331                            b"i" => identity = Some(raw_val.trim().to_string()),
332                            b"q" => query_method = Some(raw_val.trim().to_string()),
333                            _ => {} // truly unknown, ignore per §3.2
334                        }
335                    }
336                    // Else: unknown tag, ignored per RFC 6376 §3.2.
337                }
338            }
339        }
340
341        let version = version.ok_or_else(|| DkimError::MissingTag("v".into()))?;
342        let algorithm = algorithm.ok_or_else(|| DkimError::MissingTag("a".into()))?;
343        let signature_b64 = signature_b64.ok_or_else(|| DkimError::MissingTag("b".into()))?;
344        let body_hash_b64 = body_hash_b64.ok_or_else(|| DkimError::MissingTag("bh".into()))?;
345        let domain = domain.ok_or_else(|| DkimError::MissingTag("d".into()))?;
346        let selector = selector.ok_or_else(|| DkimError::MissingTag("s".into()))?;
347        let signed_headers = signed_headers.ok_or_else(|| DkimError::MissingTag("h".into()))?;
348        let query_method = query_method.unwrap_or_else(|| "dns/txt".to_string());
349        if !query_method.eq_ignore_ascii_case("dns/txt") {
350            return Err(DkimError::UnsupportedAlgorithm(format!(
351                "q={query_method}"
352            )));
353        }
354
355        Ok(DkimHeader {
356            version,
357            algorithm,
358            signature_b64,
359            body_hash_b64,
360            canon_header,
361            canon_body,
362            domain,
363            selector,
364            signed_headers,
365            body_length,
366            timestamp,
367            expiration,
368            identity,
369            query_method,
370        })
371    }
372}
373
374fn parse_canon(c: &str) -> Result<(Canon, Canon), DkimError> {
375    let c = c.trim();
376    // c= can be "header/body" or just "header" (default body = simple)
377    let (hdr, body) = match c.split_once('/') {
378        Some((h, b)) => (h.trim(), b.trim()),
379        None => (c, "simple"),
380    };
381    let h = match hdr {
382        "simple" => Canon::Simple,
383        "relaxed" => Canon::Relaxed,
384        other => return Err(DkimError::UnsupportedCanon(format!("header={other}"))),
385    };
386    let b = match body {
387        "simple" => Canon::Simple,
388        "relaxed" => Canon::Relaxed,
389        other => return Err(DkimError::UnsupportedCanon(format!("body={other}"))),
390    };
391    Ok((h, b))
392}
393
394/// Remove all WSP (space + horizontal tab) and CR/LF — used for the
395/// base64 tag values, which may have arbitrary whitespace inserted by
396/// the folding rules. Byte-level + capacity-presized; faster than the
397/// `.chars().filter().collect()` form on typical RSA-2048 base64 payloads.
398fn strip_wsp(s: &str) -> String {
399    let mut out = String::with_capacity(s.len());
400    for &b in s.as_bytes() {
401        if !matches!(b, b' ' | b'\t' | b'\r' | b'\n') {
402            out.push(b as char);
403        }
404    }
405    out
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    /// Minimal real-world DKIM-Signature (relaxed/relaxed, rsa-sha256).
413    fn sample_header() -> &'static str {
414        " v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=mail;\r\n\
415         \th=From:To:Subject:Date:Message-ID;\r\n\
416         \tbh=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;\r\n\
417         \tb=SignatureValueGoesHere"
418    }
419
420    #[test]
421    fn parse_full_header() {
422        let h = DkimHeader::parse(sample_header()).unwrap();
423        assert_eq!(h.version, 1);
424        assert_eq!(h.algorithm, Algorithm::RsaSha256);
425        assert_eq!(h.canon_header, Canon::Relaxed);
426        assert_eq!(h.canon_body, Canon::Relaxed);
427        assert_eq!(h.domain, "example.com");
428        assert_eq!(h.selector, "mail");
429        assert_eq!(
430            h.signed_headers,
431            vec!["from", "to", "subject", "date", "message-id"]
432        );
433        assert_eq!(h.body_hash_b64, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
434        assert_eq!(h.signature_b64, "SignatureValueGoesHere");
435        assert!(h.body_length.is_none());
436        assert_eq!(h.query_method, "dns/txt");
437    }
438
439    #[test]
440    fn parse_simple_canon_default() {
441        let r = DkimHeader::parse(
442            "v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=AAAA; b=BBBB",
443        )
444        .unwrap();
445        assert_eq!(r.canon_header, Canon::Simple);
446        assert_eq!(r.canon_body, Canon::Simple);
447    }
448
449    #[test]
450    fn parse_canon_relaxed_simple() {
451        let r = DkimHeader::parse(
452            "v=1; a=rsa-sha256; c=relaxed/simple; d=e.com; s=s; h=From; bh=A; b=B",
453        )
454        .unwrap();
455        assert_eq!(r.canon_header, Canon::Relaxed);
456        assert_eq!(r.canon_body, Canon::Simple);
457    }
458
459    #[test]
460    fn parse_canon_header_only_defaults_body() {
461        // "c=relaxed" without /body part → body defaults to simple
462        let r = DkimHeader::parse(
463            "v=1; a=rsa-sha256; c=relaxed; d=e.com; s=s; h=From; bh=A; b=B",
464        )
465        .unwrap();
466        assert_eq!(r.canon_header, Canon::Relaxed);
467        assert_eq!(r.canon_body, Canon::Simple);
468    }
469
470    #[test]
471    fn parse_signed_headers_lowercased() {
472        let r = DkimHeader::parse(
473            "v=1; a=rsa-sha256; d=e.com; s=s; h=From:TO:SuBjEcT; bh=A; b=B",
474        )
475        .unwrap();
476        assert_eq!(r.signed_headers, vec!["from", "to", "subject"]);
477    }
478
479    #[test]
480    fn parse_optional_l_t_x() {
481        let r = DkimHeader::parse(
482            "v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B; l=1024; t=1000; x=2000",
483        )
484        .unwrap();
485        assert_eq!(r.body_length, Some(1024));
486        assert_eq!(r.timestamp, Some(1000));
487        assert_eq!(r.expiration, Some(2000));
488    }
489
490    #[test]
491    fn parse_rejects_missing_required() {
492        // Missing `v=`
493        let r = DkimHeader::parse("a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B");
494        assert!(matches!(r, Err(DkimError::MissingTag(_))));
495    }
496
497    #[test]
498    fn parse_rejects_wrong_version() {
499        let r = DkimHeader::parse(
500            "v=2; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B",
501        );
502        assert!(matches!(r, Err(DkimError::InvalidTag(_))));
503    }
504
505    #[test]
506    fn parse_rejects_unsupported_algo() {
507        let r = DkimHeader::parse(
508            "v=1; a=rsa-sha1; d=e.com; s=s; h=From; bh=A; b=B",
509        );
510        assert!(matches!(r, Err(DkimError::UnsupportedAlgorithm(_))));
511    }
512
513    #[test]
514    fn parse_ed25519_sha256_algorithm() {
515        // RFC 8463 ed25519-sha256 is accepted in 1.1+
516        let r = DkimHeader::parse(
517            "v=1; a=ed25519-sha256; d=e.com; s=s; h=From; bh=A; b=B",
518        )
519        .unwrap();
520        assert_eq!(r.algorithm, Algorithm::Ed25519Sha256);
521    }
522
523    #[test]
524    fn parse_rejects_empty_h() {
525        let r = DkimHeader::parse(
526            "v=1; a=rsa-sha256; d=e.com; s=s; h=; bh=A; b=B",
527        );
528        assert!(matches!(r, Err(DkimError::InvalidTag(_))));
529    }
530
531    #[test]
532    fn parse_b_strips_wsp() {
533        let r = DkimHeader::parse(
534            "v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=A B\tC\r\n D",
535        )
536        .unwrap();
537        assert_eq!(r.signature_b64, "ABCD");
538    }
539
540    #[test]
541    fn parse_default_query_dns_txt() {
542        let r = DkimHeader::parse(
543            "v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B",
544        )
545        .unwrap();
546        assert_eq!(r.query_method, "dns/txt");
547    }
548
549    #[test]
550    fn parse_rejects_non_dns_query() {
551        let r = DkimHeader::parse(
552            "v=1; a=rsa-sha256; q=https; d=e.com; s=s; h=From; bh=A; b=B",
553        );
554        assert!(matches!(r, Err(DkimError::UnsupportedAlgorithm(_))));
555    }
556
557    #[test]
558    fn parse_with_i_identity() {
559        let r = DkimHeader::parse(
560            "v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B; i=user@e.com",
561        )
562        .unwrap();
563        assert_eq!(r.identity.as_deref(), Some("user@e.com"));
564    }
565}