1use compact_str::CompactString;
4
5use crate::error::DkimError;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Algorithm {
10 RsaSha256,
12 Ed25519Sha256,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Canon {
20 Simple,
23 Relaxed,
27}
28
29#[derive(Debug, Clone)]
35pub struct DkimHeader {
36 pub version: u32,
38 pub algorithm: Algorithm,
40 pub signature_b64: String,
42 pub body_hash_b64: String,
44 pub canon_header: Canon,
47 pub canon_body: Canon,
49 pub domain: CompactString,
56 pub selector: CompactString,
59 pub signed_headers: Vec<String>,
63 pub body_length: Option<u64>,
66 pub timestamp: Option<u64>,
68 pub expiration: Option<u64>,
71 pub identity: Option<CompactString>,
74 pub query_method: CompactString,
78}
79
80impl DkimHeader {
81 pub fn parse(value: &str) -> Result<Self, DkimError> {
88 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 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 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 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 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 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 _ => {} }
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
236fn 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
245fn 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' => {} b':' => {
260 if !cur.is_empty() {
261 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 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 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
303fn 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 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 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 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 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}