kumo_dkim/
header.rs

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    /// Get the named tag.
37    /// Attempt to parse it into an `R`
38    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        // Required tags are guaranteed by the parser to be present so it's safe
62        // to assert and unwrap.
63        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        // Required tags are guaranteed by the parser to be present so it's safe
71        // to assert and unwrap.
72        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    /// Generate the DKIM-Signature header from the tags
96    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            // Always emit b on a separate line for the sake of
106            // consistency of the hash, which is generated in two
107            // passes; the first with an empty b value and the second
108            // with it populated.
109            // If we don't push it to the next line, the two passes
110            // may produce inconsistent results as a result of the
111            // text wrapping and the signature will be invalid.
112            //
113            // Similarly, header lists can be rather long and we
114            // want to control how they wrap with a bit more nuance.
115            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                    // Need new line
143                    lines.push(line);
144                    line = format!("\t{name}");
145                }
146            } else {
147                if value.len() >= WIDTH {
148                    // Value will never fit even on a fresh line,
149                    // so we force it to break
150                    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    /// Check things common to DKIM-Signature and ARC-Message-Signature
173    fn check_common_tags(&self) -> Result<(), DKIMError> {
174        // Check that "h=" tag includes the From header
175        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        // Check that "x=" tag isn't expired
190        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    /// <https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1>
225    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        // Check version
232        if header.get_required_tag("v") != "1" {
233            return Err(DKIMError::IncompatibleVersion);
234        }
235
236        // Check that "d=" tag is the same as or a parent domain of the domain part
237        // of the "i=" tag
238        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/// <https://datatracker.ietf.org/doc/html/rfc8617#section-4.1.2> says
318/// The AMS header field has the same syntax and semantics as the
319/// DKIM-Signature field [RFC6376], with three (3) differences
320/// * the name of the header field itself;
321/// * no version tag ("v") is defined for the AMS header field.
322///   As required for undefined tags (in
323///   [RFC6376]), if seen, a version tag MUST be ignored.
324/// * the "i" (Agent or User Identifier (AUID)) tag is not imported from
325///   DKIM; instead, this tag is replaced by the instance tag as defined
326///   in Section 4.2.1.
327#[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            // TODO: MUST result in cv status of fail, see Section 5.1.1
394        }
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            // Something definitely failed
451            return Err(err);
452        }
453
454        // There were no errors and all keys returned false from verify_signature().
455        // That means that the signature is not verified.
456        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    /// Check that the parsed values in each tag are the same.
534    /// We don't compare the Tags directly as the raw values may
535    /// have whitespace that differs
536    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    /// This is a regression test for a wrapping issue where our
579    /// own header-list aware wrapping was in conflict with a second-pass
580    /// generic textwrap::fill operation that could break our carefully
581    /// wrapped header.
582    /// The code that triggered that condition literally no longer exists
583    /// in the code, but was triggered by this very specific combination
584    /// of tag values so we're keeping it as a regression test.
585    /// <https://github.com/KumoCorp/kumomta/pull/483> is a link to
586    /// a PR describing the issue in more detail.
587    #[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}