1use crate::error::DkimError;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Algorithm {
8 RsaSha256,
10 Ed25519Sha256,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Canon {
18 Simple,
21 Relaxed,
25}
26
27#[derive(Debug, Clone)]
33pub struct DkimHeader {
34 pub version: u32,
36 pub algorithm: Algorithm,
38 pub signature_b64: String,
40 pub body_hash_b64: String,
42 pub canon_header: Canon,
45 pub canon_body: Canon,
47 pub domain: String,
49 pub selector: String,
51 pub signed_headers: Vec<String>,
55 pub body_length: Option<u64>,
58 pub timestamp: Option<u64>,
60 pub expiration: Option<u64>,
63 pub identity: Option<String>,
66 pub query_method: String,
69}
70
71impl DkimHeader {
72 pub fn parse(value: &str) -> Result<Self, DkimError> {
79 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 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 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 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 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 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 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' => {} b':' => {
186 if !cur.is_empty() {
187 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 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 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 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 _ => {} }
335 }
336 }
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 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
394fn 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 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 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 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 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}