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;
7use textwrap::core::Word;
8
9pub(crate) const DKIM_SIGNATURE_HEADER_NAME: &str = "DKIM-Signature";
10const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
11
12#[derive(Clone, Debug, Default)]
13pub struct TaggedHeader {
14    tags: IndexMap<String, parser::Tag>,
15    raw_bytes: String,
16}
17
18impl TaggedHeader {
19    pub fn parse(value: &str) -> Result<Self, DKIMError> {
20        let (_, tags) = parser::tag_list(value)
21            .map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?;
22
23        let mut tags_map = IndexMap::new();
24        for tag in &tags {
25            tags_map.insert(tag.name.clone(), tag.clone());
26        }
27        Ok(Self {
28            tags: tags_map,
29            raw_bytes: value.to_owned(),
30        })
31    }
32
33    pub fn get_tag(&self, name: &str) -> Option<&str> {
34        self.tags.get(name).map(|v| v.value.as_str())
35    }
36
37    /// Get the named tag.
38    /// Attempt to parse it into an `R`
39    pub fn parse_tag<R>(&self, name: &str) -> Result<Option<R>, DKIMError>
40    where
41        R: FromStr,
42        <R as FromStr>::Err: std::fmt::Display,
43    {
44        match self.get_tag(name) {
45            None => Ok(None),
46            Some(value) => {
47                let value: R = value.parse().map_err(|err| {
48                    DKIMError::SignatureSyntaxError(format!(
49                        "invalid \"{name}\" tag value: {err:#}"
50                    ))
51                })?;
52                Ok(Some(value))
53            }
54        }
55    }
56
57    pub fn get_raw_tag(&self, name: &str) -> Option<&str> {
58        self.tags.get(name).map(|v| v.raw_value.as_str())
59    }
60
61    pub fn get_required_tag(&self, name: &str) -> &str {
62        // Required tags are guaranteed by the parser to be present so it's safe
63        // to assert and unwrap.
64        match self.get_tag(name) {
65            Some(value) => value,
66            None => panic!("required tag {name} is not present"),
67        }
68    }
69
70    pub fn get_required_raw_tag(&self, name: &str) -> &str {
71        // Required tags are guaranteed by the parser to be present so it's safe
72        // to assert and unwrap.
73        match self.get_raw_tag(name) {
74            Some(value) => value,
75            None => panic!("required tag {name} is not present"),
76        }
77    }
78
79    pub fn raw(&self) -> &str {
80        &self.raw_bytes
81    }
82
83    pub fn arc_instance(&self) -> Result<u8, DKIMError> {
84        let instance = self
85            .get_required_tag("i")
86            .parse::<u8>()
87            .map_err(|_| DKIMError::InvalidARCInstance)?;
88
89        if instance == 0 || instance > MAX_ARC_INSTANCE {
90            return Err(DKIMError::InvalidARCInstance);
91        }
92
93        Ok(instance)
94    }
95
96    /// Generate the DKIM-Signature header from the tags
97    fn serialize(&self) -> String {
98        let mut out = String::new();
99
100        for (key, tag) in &self.tags {
101            let mut value = &tag.value;
102            let value_storage;
103
104            if !out.is_empty() {
105                if key == "b" {
106                    // Always emit b on a separate line for the sake of
107                    // consistency of the hash, which is generated in two
108                    // passes; the first with an empty b value and the second
109                    // with it populated.
110                    // If we don't push it to the next line, the two passes
111                    // may produce inconsistent results as a result of the
112                    // textwrap::fill operation and the signature will be invalid
113                    out.push_str("\r\n");
114                } else if key == "h" {
115                    // header lists can be rather long, and we want to control
116                    // how they wrap with a bit more nuance. We'll put these
117                    // on a line of their own, and explicitly wrap the value
118                    out.push_str("\r\n");
119                    value_storage = textwrap::fill(
120                        value,
121                        textwrap::Options::new(75)
122                            .initial_indent("")
123                            .line_ending(textwrap::LineEnding::CRLF)
124                            .word_separator(textwrap::WordSeparator::Custom(|line| {
125                                let mut start = 0;
126                                let mut prev_was_colon = false;
127                                let mut char_indices = line.char_indices();
128
129                                Box::new(std::iter::from_fn(move || {
130                                    for (idx, ch) in char_indices.by_ref() {
131                                        if ch == ':' {
132                                            prev_was_colon = true;
133                                        } else if prev_was_colon {
134                                            prev_was_colon = false;
135                                            let word = Word::from(&line[start..idx]);
136                                            start = idx;
137
138                                            return Some(word);
139                                        }
140                                    }
141                                    if start < line.len() {
142                                        let word = Word::from(&line[start..]);
143                                        start = line.len();
144                                        return Some(word);
145                                    }
146                                    None
147                                }))
148                            }))
149                            .word_splitter(textwrap::WordSplitter::NoHyphenation)
150                            .subsequent_indent("\t"),
151                    );
152                    value = &value_storage;
153                } else {
154                    out.push(' ');
155                }
156            }
157            out.push_str(key);
158            out.push('=');
159            out.push_str(value);
160            out.push(';');
161        }
162        textwrap::fill(
163            &out,
164            textwrap::Options::new(75)
165                .initial_indent("")
166                .line_ending(textwrap::LineEnding::CRLF)
167                .word_separator(textwrap::WordSeparator::AsciiSpace)
168                .word_splitter(textwrap::WordSplitter::NoHyphenation)
169                .subsequent_indent("\t"),
170        )
171    }
172
173    /// Check things common to DKIM-Signature and ARC-Message-Signature
174    fn check_common_tags(&self) -> Result<(), DKIMError> {
175        // Check that "h=" tag includes the From header
176        if !self
177            .get_required_tag("h")
178            .split(':')
179            .any(|h| h.eq_ignore_ascii_case("from"))
180        {
181            return Err(DKIMError::FromFieldNotSigned);
182        }
183
184        if let Some(query_method) = self.get_tag("q") {
185            if query_method != "dns/txt" {
186                return Err(DKIMError::UnsupportedQueryMethod);
187            }
188        }
189
190        // Check that "x=" tag isn't expired
191        if let Some(expiration) = self.get_tag("x") {
192            let mut expiration =
193                chrono::DateTime::from_timestamp(expiration.parse::<i64>().unwrap_or_default(), 0)
194                    .ok_or(DKIMError::SignatureExpired)?;
195            expiration += chrono::Duration::try_minutes(SIGN_EXPIRATION_DRIFT_MINS)
196                .expect("drift to be in-range");
197            let now = chrono::Utc::now();
198            if now > expiration {
199                return Err(DKIMError::SignatureExpired);
200            }
201        }
202
203        Ok(())
204    }
205}
206
207#[derive(Debug, Clone, Default)]
208pub(crate) struct DKIMHeader {
209    tagged: TaggedHeader,
210}
211
212impl std::ops::Deref for DKIMHeader {
213    type Target = TaggedHeader;
214    fn deref(&self) -> &TaggedHeader {
215        &self.tagged
216    }
217}
218impl std::ops::DerefMut for DKIMHeader {
219    fn deref_mut(&mut self) -> &mut TaggedHeader {
220        &mut self.tagged
221    }
222}
223
224impl DKIMHeader {
225    /// <https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1>
226    pub fn parse(value: &str) -> Result<Self, DKIMError> {
227        let tagged = TaggedHeader::parse(value)?;
228        let header = DKIMHeader { tagged };
229
230        header.validate_required_tags()?;
231
232        // Check version
233        if header.get_required_tag("v") != "1" {
234            return Err(DKIMError::IncompatibleVersion);
235        }
236
237        // Check that "d=" tag is the same as or a parent domain of the domain part
238        // of the "i=" tag
239        if let Some(user) = header.get_tag("i") {
240            let signing_domain = header.get_required_tag("d");
241            let Some((_local, domain)) = user.split_once('@') else {
242                return Err(DKIMError::DomainMismatch);
243            };
244
245            let i_domain = Name::from_str_relaxed(domain).map_err(|_| DKIMError::DomainMismatch)?;
246            let d_domain =
247                Name::from_str_relaxed(signing_domain).map_err(|_| DKIMError::DomainMismatch)?;
248
249            if !d_domain.zone_of(&i_domain) {
250                return Err(DKIMError::DomainMismatch);
251            }
252        }
253
254        header.check_common_tags()?;
255
256        Ok(header)
257    }
258
259    fn validate_required_tags(&self) -> Result<(), DKIMError> {
260        const REQUIRED_TAGS: &[&str] = &["v", "a", "b", "bh", "d", "h", "s"];
261        for required in REQUIRED_TAGS {
262            if self.get_tag(required).is_none() {
263                return Err(DKIMError::SignatureMissingRequiredTag(required));
264            }
265        }
266        Ok(())
267    }
268}
269
270#[derive(Clone)]
271pub(crate) struct TaggedHeaderBuilder {
272    header: TaggedHeader,
273    time: Option<chrono::DateTime<chrono::offset::Utc>>,
274}
275impl TaggedHeaderBuilder {
276    pub(crate) fn new() -> Self {
277        TaggedHeaderBuilder {
278            header: TaggedHeader::default(),
279            time: None,
280        }
281    }
282
283    pub(crate) fn add_tag(mut self, name: &str, value: &str) -> Self {
284        let tag = parser::Tag {
285            name: name.to_owned(),
286            value: value.to_owned(),
287            raw_value: value.to_owned(),
288        };
289        self.header.tags.insert(name.to_owned(), tag);
290
291        self
292    }
293
294    pub(crate) fn set_signed_headers(self, headers: &HeaderList) -> Self {
295        let value = headers.as_h_list();
296        self.add_tag("h", &value)
297    }
298
299    pub(crate) fn set_expiry(self, duration: chrono::Duration) -> Result<Self, DKIMError> {
300        let time = self.time.ok_or(DKIMError::BuilderError(
301            "TaggedHeaderBuilder: set_time must be called prior to calling set_expiry",
302        ))?;
303        let expiry = (time + duration).timestamp();
304        Ok(self.add_tag("x", &expiry.to_string()))
305    }
306
307    pub(crate) fn set_time(mut self, time: chrono::DateTime<chrono::offset::Utc>) -> Self {
308        self.time = Some(time);
309        self.add_tag("t", &time.timestamp().to_string())
310    }
311
312    pub(crate) fn build(mut self) -> TaggedHeader {
313        self.header.raw_bytes = self.header.serialize();
314        self.header
315    }
316}
317
318/// <https://datatracker.ietf.org/doc/html/rfc8617#section-4.1.2> says
319/// The AMS header field has the same syntax and semantics as the
320/// DKIM-Signature field [RFC6376], with three (3) differences
321/// * the name of the header field itself;
322/// * no version tag ("v") is defined for the AMS header field.
323///   As required for undefined tags (in
324///   [RFC6376]), if seen, a version tag MUST be ignored.
325/// * the "i" (Agent or User Identifier (AUID)) tag is not imported from
326///   DKIM; instead, this tag is replaced by the instance tag as defined
327///   in Section 4.2.1.
328#[derive(Debug, Clone, Default)]
329pub struct ARCMessageSignatureHeader {
330    tagged: TaggedHeader,
331}
332
333impl std::ops::Deref for ARCMessageSignatureHeader {
334    type Target = TaggedHeader;
335    fn deref(&self) -> &TaggedHeader {
336        &self.tagged
337    }
338}
339impl std::ops::DerefMut for ARCMessageSignatureHeader {
340    fn deref_mut(&mut self) -> &mut TaggedHeader {
341        &mut self.tagged
342    }
343}
344
345impl ARCMessageSignatureHeader {
346    pub fn parse(value: &str) -> Result<Self, DKIMError> {
347        let tagged = TaggedHeader::parse(value)?;
348        let header = Self { tagged };
349
350        header.validate_required_tags()?;
351        header.check_common_tags()?;
352        header.arc_instance()?;
353
354        Ok(header)
355    }
356
357    fn validate_required_tags(&self) -> Result<(), DKIMError> {
358        const REQUIRED_TAGS: &[&str] = &["a", "b", "bh", "d", "h", "s", "i"];
359        for required in REQUIRED_TAGS {
360            if self.get_tag(required).is_none() {
361                return Err(DKIMError::SignatureMissingRequiredTag(required));
362            }
363        }
364        Ok(())
365    }
366}
367
368#[derive(Debug, Clone, Default)]
369pub struct ARCSealHeader {
370    tagged: TaggedHeader,
371}
372
373impl std::ops::Deref for ARCSealHeader {
374    type Target = TaggedHeader;
375    fn deref(&self) -> &TaggedHeader {
376        &self.tagged
377    }
378}
379impl std::ops::DerefMut for ARCSealHeader {
380    fn deref_mut(&mut self) -> &mut TaggedHeader {
381        &mut self.tagged
382    }
383}
384
385impl ARCSealHeader {
386    pub fn parse(value: &str) -> Result<Self, DKIMError> {
387        let tagged = TaggedHeader::parse(value)?;
388        let header = Self { tagged };
389
390        header.validate_required_tags()?;
391        header.arc_instance()?;
392
393        if header.get_tag("h").is_some() {
394            // TODO: MUST result in cv status of fail, see Section 5.1.1
395        }
396
397        Ok(header)
398    }
399
400    fn validate_required_tags(&self) -> Result<(), DKIMError> {
401        const REQUIRED_TAGS: &[&str] = &["a", "b", "d", "s", "i", "cv"];
402        for required in REQUIRED_TAGS {
403            if self.get_tag(required).is_none() {
404                return Err(DKIMError::SignatureMissingRequiredTag(required));
405            }
406        }
407        Ok(())
408    }
409
410    pub async fn verify(
411        &self,
412        resolver: &dyn Resolver,
413        header_list: &Vec<&Header<'_>>,
414    ) -> Result<(), DKIMError> {
415        let public_keys = crate::public_key::retrieve_public_keys(
416            resolver,
417            self.get_required_tag("d"),
418            self.get_required_tag("s"),
419        )
420        .await?;
421
422        let hash_algo = parser::parse_hash_algo(self.get_required_tag("a"))?;
423
424        let computed_headers_hash = crate::hash::compute_headers_hash(
425            crate::canonicalization::Type::Relaxed,
426            &header_list,
427            hash_algo,
428            self,
429            ARC_SEAL_HEADER_NAME,
430        )?;
431
432        let signature = data_encoding::BASE64
433            .decode(self.get_required_tag("b").as_bytes())
434            .map_err(|err| {
435                DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
436            })?;
437
438        let mut errors = vec![];
439        for public_key in public_keys {
440            match crate::verify_signature(hash_algo, &computed_headers_hash, &signature, public_key)
441            {
442                Ok(true) => return Ok(()),
443                Ok(false) => {}
444                Err(err) => {
445                    errors.push(err);
446                }
447            }
448        }
449
450        if let Some(err) = errors.pop() {
451            // Something definitely failed
452            return Err(err);
453        }
454
455        // There were no errors and all keys returned false from verify_signature().
456        // That means that the signature is not verified.
457        Err(DKIMError::SignatureDidNotVerify)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_dkim_header_builder() {
467        let header = TaggedHeaderBuilder::new()
468            .add_tag("v", "1")
469            .add_tag("a", "something")
470            .build();
471        k9::snapshot!(header.raw(), "v=1; a=something;");
472    }
473
474    fn signed_header_list(headers: &[&str]) -> HeaderList {
475        HeaderList::new(headers.iter().map(|h| h.to_lowercase()).collect())
476    }
477
478    #[test]
479    fn test_dkim_header_builder_signed_headers() {
480        let header = TaggedHeaderBuilder::new()
481            .add_tag("v", "2")
482            .set_signed_headers(&signed_header_list(&["header1", "header2", "header3"]))
483            .build();
484        k9::snapshot!(
485            header.raw(),
486            r#"
487v=2;\r
488\th=header1:header2:header3;
489"#
490        );
491    }
492
493    #[test]
494    fn test_dkim_header_builder_time() {
495        use chrono::TimeZone;
496
497        let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
498
499        let header = TaggedHeaderBuilder::new()
500            .set_time(time)
501            .set_expiry(chrono::Duration::try_hours(3).expect("3 hours ok"))
502            .unwrap()
503            .build();
504        k9::snapshot!(header.raw(), "t=1609459201; x=1609470001;");
505    }
506
507    #[test]
508    fn test_parse_ams() {
509        let sig = "i=1; a=rsa-sha256; c=relaxed/relaxed; d=
510    messagingengine.com; h=date:from:reply-to:to:message-id:subject
511    :mime-version:content-type:content-transfer-encoding; s=fm3; t=
512    1761717439; bh=+BM/Umiva3F0xjsh9a2BcwzO1nr0Ru6oGRmgkMy9T3M=; b=I
513    M7xjn2qSjOx5fDFvQY+pEPJ74+w3h/UOZUKvdAt7gRP8rAe9C+Tz72izVJyY82xw
514    7LT7CBXnwk2DQpg9erhq1yYept4M5CKWLXoQHHUJam8mV4RMUnHgTLVlColIVUtY
515    hNAomZdsGNiG1iRGX0C4y81zYANJ11TXKOTvfuMLhG2uDIa8768O5jBa4jlBtGHd
516    Dn/87/T/J+plO/ZPiSwWKa+ZttR6yjwm0fdpXf+4y8u0+I8iYSw2EN0vgWMYEEMp
517    R1xuhMKD+bSlx130Rz2/5jFsVgLS7CfbTKK5CtqS3hl6EaLw/REBZeCYCHltzRWF
518    wt38/NIzJ3ykCswwds2YQ==";
519        ARCMessageSignatureHeader::parse(sig).unwrap();
520    }
521
522    #[test]
523    fn test_parse_as() {
524        let seal = "i=1; a=rsa-sha256; cv=none; d=messagingengine.com; s=fm3; t=
525    1761717439; b=Q1E9HuR4H0paxIiz15H8P3tGfzDp0XmYKhvyzGsPEBHr2xg610
526    ZV1nU6gLWmUl693usMKVxWGrIXbSZb13ICRK0gp1MfVJSQ/4IGM0VD9P5d9Vv7aL
527    Q/lx/a8Ar1ks1yEHeBRuZ6Q5GdYur8rgYr7UoOTJGwOOPTJ4C2TWGoHHIRoVECJv
528    mMa6jpcJ6SE6iK/76elugk65BheumbQ1YEnbjitchUsLAwSXMuO+mhLYGtmvBhOn
529    v3ewYQvD2jZzl2W+O73A08dQ/oeODDPqt6Fpv3XK572cTYPHhzmSbsxh9Lp7Z9MV
530    x2TACmO51Adnp3C1CcEw8K9ajAgyjNMW4ELA==";
531        ARCSealHeader::parse(seal).unwrap();
532    }
533}