1use crate::arc::{ARC_SEAL_HEADER_NAME, MAX_ARC_INSTANCE};
2use crate::{parser, DKIMError, HeaderList};
3use dns_resolver::{Name, Resolver};
4use indexmap::map::IndexMap;
5use mailparsing::Header;
6use std::str::FromStr;
7
8pub(crate) const DKIM_SIGNATURE_HEADER_NAME: &str = "DKIM-Signature";
9const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
10
11#[derive(Clone, Debug, Default)]
12pub struct TaggedHeader {
13 tags: IndexMap<String, parser::Tag>,
14 raw_bytes: String,
15}
16
17impl TaggedHeader {
18 pub fn parse(value: &str) -> Result<Self, DKIMError> {
19 let (_, tags) = parser::tag_list(value)
20 .map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?;
21
22 let mut tags_map = IndexMap::new();
23 for tag in &tags {
24 tags_map.insert(tag.name.clone(), tag.clone());
25 }
26 Ok(Self {
27 tags: tags_map,
28 raw_bytes: value.to_owned(),
29 })
30 }
31
32 pub fn get_tag(&self, name: &str) -> Option<&str> {
33 self.tags.get(name).map(|v| v.value.as_str())
34 }
35
36 pub fn parse_tag<R>(&self, name: &str) -> Result<Option<R>, DKIMError>
39 where
40 R: FromStr,
41 <R as FromStr>::Err: std::fmt::Display,
42 {
43 match self.get_tag(name) {
44 None => Ok(None),
45 Some(value) => {
46 let value: R = value.parse().map_err(|err| {
47 DKIMError::SignatureSyntaxError(format!(
48 "invalid \"{name}\" tag value: {err:#}"
49 ))
50 })?;
51 Ok(Some(value))
52 }
53 }
54 }
55
56 pub fn get_raw_tag(&self, name: &str) -> Option<&str> {
57 self.tags.get(name).map(|v| v.raw_value.as_str())
58 }
59
60 pub fn get_required_tag(&self, name: &str) -> &str {
61 match self.get_tag(name) {
64 Some(value) => value,
65 None => panic!("required tag {name} is not present"),
66 }
67 }
68
69 pub fn get_required_raw_tag(&self, name: &str) -> &str {
70 match self.get_raw_tag(name) {
73 Some(value) => value,
74 None => panic!("required tag {name} is not present"),
75 }
76 }
77
78 pub fn raw(&self) -> &str {
79 &self.raw_bytes
80 }
81
82 pub fn arc_instance(&self) -> Result<u8, DKIMError> {
83 let instance = self
84 .get_required_tag("i")
85 .parse::<u8>()
86 .map_err(|_| DKIMError::InvalidARCInstance)?;
87
88 if instance == 0 || instance > MAX_ARC_INSTANCE {
89 return Err(DKIMError::InvalidARCInstance);
90 }
91
92 Ok(instance)
93 }
94
95 fn serialize(&self) -> String {
97 let mut lines = vec![];
98 let mut line = String::new();
99
100 const WIDTH: usize = 75;
101
102 for (key, tag) in &self.tags {
103 let value = &tag.value;
104
105 let always_new_line = key == "b" || key == "h";
116
117 if always_new_line || (line.len() + key.len() + 2 + value.len() >= WIDTH) {
118 if !line.is_empty() {
119 lines.push(line.clone());
120 line.clear();
121 }
122 }
123
124 if !line.is_empty() {
125 line.push(' ');
126 }
127 line.push_str(key);
128 line.push('=');
129
130 if line.len() + value.len() < WIDTH {
131 line.push_str(value);
132 } else if key == "h" {
133 for (idx, name) in value.split(':').enumerate() {
134 if idx > 0 {
135 line.push(':');
136 }
137 if line.len() + name.len() < WIDTH {
138 line.push_str(name);
139 continue;
140 }
141
142 lines.push(line);
144 line = format!("\t{name}");
145 }
146 } else {
147 if value.len() >= WIDTH {
148 for c in value.chars() {
151 line.push(c);
152 if line.len() >= WIDTH {
153 lines.push(line.clone());
154 line.clear();
155 }
156 }
157 } else {
158 lines.push(line);
159 line = format!("\t{value}");
160 }
161 }
162 line.push(';');
163 }
164
165 if !line.is_empty() {
166 lines.push(line);
167 }
168
169 lines.join("\r\n\t")
170 }
171
172 fn check_common_tags(&self) -> Result<(), DKIMError> {
174 if !self
176 .get_required_tag("h")
177 .split(':')
178 .any(|h| h.eq_ignore_ascii_case("from"))
179 {
180 return Err(DKIMError::FromFieldNotSigned);
181 }
182
183 if let Some(query_method) = self.get_tag("q") {
184 if query_method != "dns/txt" {
185 return Err(DKIMError::UnsupportedQueryMethod);
186 }
187 }
188
189 if let Some(expiration) = self.get_tag("x") {
191 let mut expiration =
192 chrono::DateTime::from_timestamp(expiration.parse::<i64>().unwrap_or_default(), 0)
193 .ok_or(DKIMError::SignatureExpired)?;
194 expiration += chrono::Duration::try_minutes(SIGN_EXPIRATION_DRIFT_MINS)
195 .expect("drift to be in-range");
196 let now = chrono::Utc::now();
197 if now > expiration {
198 return Err(DKIMError::SignatureExpired);
199 }
200 }
201
202 Ok(())
203 }
204}
205
206#[derive(Debug, Clone, Default)]
207pub(crate) struct DKIMHeader {
208 tagged: TaggedHeader,
209}
210
211impl std::ops::Deref for DKIMHeader {
212 type Target = TaggedHeader;
213 fn deref(&self) -> &TaggedHeader {
214 &self.tagged
215 }
216}
217impl std::ops::DerefMut for DKIMHeader {
218 fn deref_mut(&mut self) -> &mut TaggedHeader {
219 &mut self.tagged
220 }
221}
222
223impl DKIMHeader {
224 pub fn parse(value: &str) -> Result<Self, DKIMError> {
226 let tagged = TaggedHeader::parse(value)?;
227 let header = DKIMHeader { tagged };
228
229 header.validate_required_tags()?;
230
231 if header.get_required_tag("v") != "1" {
233 return Err(DKIMError::IncompatibleVersion);
234 }
235
236 if let Some(user) = header.get_tag("i") {
239 let signing_domain = header.get_required_tag("d");
240 let Some((_local, domain)) = user.split_once('@') else {
241 return Err(DKIMError::DomainMismatch);
242 };
243
244 let i_domain = Name::from_str_relaxed(domain).map_err(|_| DKIMError::DomainMismatch)?;
245 let d_domain =
246 Name::from_str_relaxed(signing_domain).map_err(|_| DKIMError::DomainMismatch)?;
247
248 if !d_domain.zone_of(&i_domain) {
249 return Err(DKIMError::DomainMismatch);
250 }
251 }
252
253 header.check_common_tags()?;
254
255 Ok(header)
256 }
257
258 fn validate_required_tags(&self) -> Result<(), DKIMError> {
259 const REQUIRED_TAGS: &[&str] = &["v", "a", "b", "bh", "d", "h", "s"];
260 for required in REQUIRED_TAGS {
261 if self.get_tag(required).is_none() {
262 return Err(DKIMError::SignatureMissingRequiredTag(required));
263 }
264 }
265 Ok(())
266 }
267}
268
269#[derive(Clone)]
270pub(crate) struct TaggedHeaderBuilder {
271 header: TaggedHeader,
272 time: Option<chrono::DateTime<chrono::offset::Utc>>,
273}
274impl TaggedHeaderBuilder {
275 pub(crate) fn new() -> Self {
276 TaggedHeaderBuilder {
277 header: TaggedHeader::default(),
278 time: None,
279 }
280 }
281
282 pub(crate) fn add_tag(mut self, name: &str, value: &str) -> Self {
283 let tag = parser::Tag {
284 name: name.to_owned(),
285 value: value.to_owned(),
286 raw_value: value.to_owned(),
287 };
288 self.header.tags.insert(name.to_owned(), tag);
289
290 self
291 }
292
293 pub(crate) fn set_signed_headers(self, headers: &HeaderList) -> Self {
294 let value = headers.as_h_list();
295 self.add_tag("h", &value)
296 }
297
298 pub(crate) fn set_expiry(self, duration: chrono::Duration) -> Result<Self, DKIMError> {
299 let time = self.time.ok_or(DKIMError::BuilderError(
300 "TaggedHeaderBuilder: set_time must be called prior to calling set_expiry",
301 ))?;
302 let expiry = (time + duration).timestamp();
303 Ok(self.add_tag("x", &expiry.to_string()))
304 }
305
306 pub(crate) fn set_time(mut self, time: chrono::DateTime<chrono::offset::Utc>) -> Self {
307 self.time = Some(time);
308 self.add_tag("t", &time.timestamp().to_string())
309 }
310
311 pub(crate) fn build(mut self) -> TaggedHeader {
312 self.header.raw_bytes = self.header.serialize();
313 self.header
314 }
315}
316
317#[derive(Debug, Clone, Default)]
328pub struct ARCMessageSignatureHeader {
329 tagged: TaggedHeader,
330}
331
332impl std::ops::Deref for ARCMessageSignatureHeader {
333 type Target = TaggedHeader;
334 fn deref(&self) -> &TaggedHeader {
335 &self.tagged
336 }
337}
338impl std::ops::DerefMut for ARCMessageSignatureHeader {
339 fn deref_mut(&mut self) -> &mut TaggedHeader {
340 &mut self.tagged
341 }
342}
343
344impl ARCMessageSignatureHeader {
345 pub fn parse(value: &str) -> Result<Self, DKIMError> {
346 let tagged = TaggedHeader::parse(value)?;
347 let header = Self { tagged };
348
349 header.validate_required_tags()?;
350 header.check_common_tags()?;
351 header.arc_instance()?;
352
353 Ok(header)
354 }
355
356 fn validate_required_tags(&self) -> Result<(), DKIMError> {
357 const REQUIRED_TAGS: &[&str] = &["a", "b", "bh", "d", "h", "s", "i"];
358 for required in REQUIRED_TAGS {
359 if self.get_tag(required).is_none() {
360 return Err(DKIMError::SignatureMissingRequiredTag(required));
361 }
362 }
363 Ok(())
364 }
365}
366
367#[derive(Debug, Clone, Default)]
368pub struct ARCSealHeader {
369 tagged: TaggedHeader,
370}
371
372impl std::ops::Deref for ARCSealHeader {
373 type Target = TaggedHeader;
374 fn deref(&self) -> &TaggedHeader {
375 &self.tagged
376 }
377}
378impl std::ops::DerefMut for ARCSealHeader {
379 fn deref_mut(&mut self) -> &mut TaggedHeader {
380 &mut self.tagged
381 }
382}
383
384impl ARCSealHeader {
385 pub fn parse(value: &str) -> Result<Self, DKIMError> {
386 let tagged = TaggedHeader::parse(value)?;
387 let header = Self { tagged };
388
389 header.validate_required_tags()?;
390 header.arc_instance()?;
391
392 if header.get_tag("h").is_some() {
393 }
395
396 Ok(header)
397 }
398
399 fn validate_required_tags(&self) -> Result<(), DKIMError> {
400 const REQUIRED_TAGS: &[&str] = &["a", "b", "d", "s", "i", "cv"];
401 for required in REQUIRED_TAGS {
402 if self.get_tag(required).is_none() {
403 return Err(DKIMError::SignatureMissingRequiredTag(required));
404 }
405 }
406 Ok(())
407 }
408
409 pub async fn verify(
410 &self,
411 resolver: &dyn Resolver,
412 header_list: &Vec<&Header<'_>>,
413 ) -> Result<(), DKIMError> {
414 let public_keys = crate::public_key::retrieve_public_keys(
415 resolver,
416 self.get_required_tag("d"),
417 self.get_required_tag("s"),
418 )
419 .await?;
420
421 let hash_algo = parser::parse_hash_algo(self.get_required_tag("a"))?;
422
423 let computed_headers_hash = crate::hash::compute_headers_hash(
424 crate::canonicalization::Type::Relaxed,
425 &header_list,
426 hash_algo,
427 self,
428 ARC_SEAL_HEADER_NAME,
429 )?;
430
431 let signature = data_encoding::BASE64
432 .decode(self.get_required_tag("b").as_bytes())
433 .map_err(|err| {
434 DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
435 })?;
436
437 let mut errors = vec![];
438 for public_key in public_keys {
439 match crate::verify_signature(hash_algo, &computed_headers_hash, &signature, public_key)
440 {
441 Ok(true) => return Ok(()),
442 Ok(false) => {}
443 Err(err) => {
444 errors.push(err);
445 }
446 }
447 }
448
449 if let Some(err) = errors.pop() {
450 return Err(err);
452 }
453
454 Err(DKIMError::SignatureDidNotVerify)
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_dkim_header_builder() {
466 let header = TaggedHeaderBuilder::new()
467 .add_tag("v", "1")
468 .add_tag("a", "something")
469 .build();
470 k9::snapshot!(header.raw(), "v=1; a=something;");
471 }
472
473 fn signed_header_list(headers: &[&str]) -> HeaderList {
474 HeaderList::new(headers.iter().map(|h| h.to_lowercase()).collect())
475 }
476
477 #[test]
478 fn test_dkim_header_builder_signed_headers() {
479 let header = TaggedHeaderBuilder::new()
480 .add_tag("v", "2")
481 .set_signed_headers(&signed_header_list(&["header1", "header2", "header3"]))
482 .build();
483 k9::snapshot!(
484 header.raw(),
485 r#"
486v=2;\r
487\th=header1:header2:header3;
488"#
489 );
490 }
491
492 #[test]
493 fn test_dkim_header_builder_time() {
494 use chrono::TimeZone;
495
496 let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
497
498 let header = TaggedHeaderBuilder::new()
499 .set_time(time)
500 .set_expiry(chrono::Duration::try_hours(3).expect("3 hours ok"))
501 .unwrap()
502 .build();
503 k9::snapshot!(header.raw(), "t=1609459201; x=1609470001;");
504 }
505
506 #[test]
507 fn test_parse_ams() {
508 let sig = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=
509 messagingengine.com; h=date:from:reply-to:to:message-id:subject
510 :mime-version:content-type:content-transfer-encoding; s=fm3; t=
511 1761717439; bh=+BM/Umiva3F0xjsh9a2BcwzO1nr0Ru6oGRmgkMy9T3M=; b=I
512 M7xjn2qSjOx5fDFvQY+pEPJ74+w3h/UOZUKvdAt7gRP8rAe9C+Tz72izVJyY82xw
513 7LT7CBXnwk2DQpg9erhq1yYept4M5CKWLXoQHHUJam8mV4RMUnHgTLVlColIVUtY
514 hNAomZdsGNiG1iRGX0C4y81zYANJ11TXKOTvfuMLhG2uDIa8768O5jBa4jlBtGHd
515 Dn/87/T/J+plO/ZPiSwWKa+ZttR6yjwm0fdpXf+4y8u0+I8iYSw2EN0vgWMYEEMp
516 R1xuhMKD+bSlx130Rz2/5jFsVgLS7CfbTKK5CtqS3hl6EaLw/REBZeCYCHltzRWF
517 wt38/NIzJ3ykCswwds2YQ==";
518 ARCMessageSignatureHeader::parse(sig).unwrap();
519 }
520
521 #[test]
522 fn test_parse_as() {
523 let seal = "i=1; a=rsa-sha256; cv=none; d=messagingengine.com; s=fm3; t=
524 1761717439; b=Q1E9HuR4H0paxIiz15H8P3tGfzDp0XmYKhvyzGsPEBHr2xg610
525 ZV1nU6gLWmUl693usMKVxWGrIXbSZb13ICRK0gp1MfVJSQ/4IGM0VD9P5d9Vv7aL
526 Q/lx/a8Ar1ks1yEHeBRuZ6Q5GdYur8rgYr7UoOTJGwOOPTJ4C2TWGoHHIRoVECJv
527 mMa6jpcJ6SE6iK/76elugk65BheumbQ1YEnbjitchUsLAwSXMuO+mhLYGtmvBhOn
528 v3ewYQvD2jZzl2W+O73A08dQ/oeODDPqt6Fpv3XK572cTYPHhzmSbsxh9Lp7Z9MV
529 x2TACmO51Adnp3C1CcEw8K9ajAgyjNMW4ELA==";
530 ARCSealHeader::parse(seal).unwrap();
531 }
532
533 fn check_tagged_header_equality(a: &TaggedHeader, b: &TaggedHeader) {
537 use std::collections::HashMap;
538 let a: HashMap<String, String> = a
539 .tags
540 .values()
541 .map(|t| (t.name.clone(), t.value.clone()))
542 .collect();
543 let b: HashMap<String, String> = b
544 .tags
545 .values()
546 .map(|t| (t.name.clone(), t.value.clone()))
547 .collect();
548 k9::assert_equal!(a, b);
549 }
550
551 fn make_tagged_header(domain: impl Into<String>) -> TaggedHeader {
552 let headers = vec![
553 "from",
554 "to",
555 "message-id",
556 "date",
557 "subject",
558 "content-type",
559 "mime-version",
560 "list-unsubscribe",
561 "list-unsubscribe-post",
562 ];
563
564 let domain = domain.into();
565
566 TaggedHeaderBuilder::new()
567 .add_tag("v", "1")
568 .add_tag("a", "rsa-sha256")
569 .add_tag("d", &domain)
570 .add_tag("s", "stage")
571 .add_tag("c", "relaxed/relaxed")
572 .set_signed_headers(&signed_header_list(&headers))
573 .add_tag("bh", "ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=")
574 .add_tag("b", "abc123def456xyz789==")
575 .build()
576 }
577
578 #[test]
588 fn test_long_header_list_with_wrapping() {
589 let header = make_tagged_header("adobe-campaign.com");
590
591 let raw = &header.raw_bytes;
592 k9::snapshot!(
593 &raw,
594 r#"
595v=1; a=rsa-sha256; d=adobe-campaign.com; s=stage; c=relaxed/relaxed;\r
596\th=from:to:message-id:date:subject:content-type:mime-version:\r
597\t\tlist-unsubscribe:list-unsubscribe-post;\r
598\tbh=ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=;\r
599\tb=abc123def456xyz789==;
600"#
601 );
602
603 let round_trip = TaggedHeader::parse(raw).unwrap();
604 check_tagged_header_equality(&header, &round_trip);
605 }
606
607 #[test]
608 fn test_wrapping_2() {
609 let header = make_tagged_header(format!("{}.com", "a".repeat(76)));
610
611 let raw = &header.raw_bytes;
612 k9::snapshot!(
613 &raw,
614 r#"
615v=1; a=rsa-sha256;\r
616\td=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r
617\taaa.com; s=stage; c=relaxed/relaxed;\r
618\th=from:to:message-id:date:subject:content-type:mime-version:\r
619\t\tlist-unsubscribe:list-unsubscribe-post;\r
620\tbh=ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=;\r
621\tb=abc123def456xyz789==;
622"#
623 );
624
625 let round_trip = TaggedHeader::parse(raw).unwrap();
626 check_tagged_header_equality(&header, &round_trip);
627 }
628
629 #[test]
630 fn test_wrapping_3() {
631 let header = make_tagged_header(format!("{}.com", "a".repeat(50)));
632
633 let raw = &header.raw_bytes;
634 k9::snapshot!(
635 &raw,
636 r#"
637v=1; a=rsa-sha256;\r
638\td=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com; s=stage;\r
639\tc=relaxed/relaxed;\r
640\th=from:to:message-id:date:subject:content-type:mime-version:\r
641\t\tlist-unsubscribe:list-unsubscribe-post;\r
642\tbh=ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=;\r
643\tb=abc123def456xyz789==;
644"#
645 );
646
647 let round_trip = TaggedHeader::parse(raw).unwrap();
648 check_tagged_header_equality(&header, &round_trip);
649 }
650
651 #[test]
652 fn test_wrapping_4() {
653 let header = make_tagged_header(format!("{}.com", "a".repeat(49)));
654
655 let raw = &header.raw_bytes;
656 k9::snapshot!(
657 &raw,
658 r#"
659v=1; a=rsa-sha256; d=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com;\r
660\ts=stage; c=relaxed/relaxed;\r
661\th=from:to:message-id:date:subject:content-type:mime-version:\r
662\t\tlist-unsubscribe:list-unsubscribe-post;\r
663\tbh=ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=;\r
664\tb=abc123def456xyz789==;
665"#
666 );
667
668 let round_trip = TaggedHeader::parse(raw).unwrap();
669 check_tagged_header_equality(&header, &round_trip);
670 }
671
672 #[test]
673 fn test_wrapping_5() {
674 let header = make_tagged_header(format!("{}.com", "a".repeat(70)));
675
676 let raw = &header.raw_bytes;
677 k9::snapshot!(
678 &raw,
679 r#"
680v=1; a=rsa-sha256;\r
681\td=\r
682\t\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com;\r
683\ts=stage; c=relaxed/relaxed;\r
684\th=from:to:message-id:date:subject:content-type:mime-version:\r
685\t\tlist-unsubscribe:list-unsubscribe-post;\r
686\tbh=ecGWgWCJeWxJFeM0urOVWP+KOlqqvsQYKOpYUP8nk7I=;\r
687\tb=abc123def456xyz789==;
688"#
689 );
690
691 let round_trip = TaggedHeader::parse(raw).unwrap();
692 check_tagged_header_equality(&header, &round_trip);
693 }
694}