Skip to main content

mailrs_dkim/
header.rs

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