kumo_dkim/
header.rs

1use crate::{parser, DKIMError, HeaderList};
2use indexmap::map::IndexMap;
3use std::str::FromStr;
4use textwrap::core::Word;
5
6pub(crate) const HEADER: &str = "DKIM-Signature";
7const REQUIRED_TAGS: &[&str] = &["v", "a", "b", "bh", "d", "h", "s"];
8const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
9
10#[derive(Debug, Clone)]
11pub(crate) struct DKIMHeader {
12    tags: IndexMap<String, parser::Tag>,
13    pub raw_bytes: String,
14}
15
16impl DKIMHeader {
17    /// <https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1>
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        let header = DKIMHeader {
27            tags: tags_map,
28            raw_bytes: value.to_owned(),
29        };
30
31        header.validate_required_tags()?;
32
33        // Check version
34        {
35            let version = header.get_required_tag("v");
36            if version != "1" {
37                return Err(DKIMError::IncompatibleVersion);
38            }
39        }
40
41        // Check that "d=" tag is the same as or a parent domain of the domain part
42        // of the "i=" tag
43        if let Some(user) = header.get_tag("i") {
44            let signing_domain = header.get_required_tag("d");
45            // TODO: naive check, should switch to parsing the domains/email
46            if !user.ends_with(&signing_domain) {
47                return Err(DKIMError::DomainMismatch);
48            }
49        }
50
51        // Check that "h=" tag includes the From header
52        {
53            let value = header.get_required_tag("h");
54            let headers = value.split(':');
55            let headers: Vec<String> = headers.map(|h| h.to_lowercase()).collect();
56            if !headers.contains(&"from".to_string()) {
57                return Err(DKIMError::FromFieldNotSigned);
58            }
59        }
60
61        if let Some(query_method) = header.get_tag("q") {
62            if query_method != "dns/txt" {
63                return Err(DKIMError::UnsupportedQueryMethod);
64            }
65        }
66
67        // Check that "x=" tag isn't expired
68        if let Some(expiration) = header.get_tag("x") {
69            let mut expiration =
70                chrono::DateTime::from_timestamp(expiration.parse::<i64>().unwrap_or_default(), 0)
71                    .ok_or(DKIMError::SignatureExpired)?;
72            expiration += chrono::Duration::try_minutes(SIGN_EXPIRATION_DRIFT_MINS)
73                .expect("drift to be in-range");
74            let now = chrono::Utc::now();
75            if now > expiration {
76                return Err(DKIMError::SignatureExpired);
77            }
78        }
79
80        Ok(header)
81    }
82
83    pub fn get_tag(&self, name: &str) -> Option<&str> {
84        self.tags.get(name).map(|v| v.value.as_str())
85    }
86
87    /// Get the named tag.
88    /// Attempt to parse it into an `R`
89    pub fn parse_tag<R>(&self, name: &str) -> Result<Option<R>, DKIMError>
90    where
91        R: FromStr,
92        <R as FromStr>::Err: std::fmt::Display,
93    {
94        match self.get_tag(name) {
95            None => Ok(None),
96            Some(value) => {
97                let value: R = value.parse().map_err(|err| {
98                    DKIMError::SignatureSyntaxError(format!(
99                        "invalid \"{name}\" tag value: {err:#}"
100                    ))
101                })?;
102                Ok(Some(value))
103            }
104        }
105    }
106
107    pub fn get_raw_tag(&self, name: &str) -> Option<&str> {
108        self.tags.get(name).map(|v| v.raw_value.as_str())
109    }
110
111    pub fn get_required_tag(&self, name: &str) -> &str {
112        // Required tags are guaranteed by the parser to be present so it's safe
113        // to assert and unwrap.
114        match self.get_tag(name) {
115            Some(value) => value,
116            None => panic!("required tag {name} is not present"),
117        }
118    }
119
120    pub fn get_required_raw_tag(&self, name: &str) -> &str {
121        // Required tags are guaranteed by the parser to be present so it's safe
122        // to assert and unwrap.
123        match self.get_raw_tag(name) {
124            Some(value) => value,
125            None => panic!("required tag {name} is not present"),
126        }
127    }
128
129    fn validate_required_tags(&self) -> Result<(), DKIMError> {
130        for required in REQUIRED_TAGS {
131            if self.get_tag(required).is_none() {
132                return Err(DKIMError::SignatureMissingRequiredTag(required));
133            }
134        }
135        Ok(())
136    }
137}
138
139/// Generate the DKIM-Signature header from the tags
140fn serialize(header: DKIMHeader) -> String {
141    let mut out = String::new();
142
143    for (key, tag) in &header.tags {
144        let mut value = &tag.value;
145        let value_storage;
146
147        if !out.is_empty() {
148            if key == "b" {
149                // Always emit b on a separate line for the sake of
150                // consistency of the hash, which is generated in two
151                // passes; the first with an empty b value and the second
152                // with it populated.
153                // If we don't push it to the next line, the two passes
154                // may produce inconsistent results as a result of the
155                // textwrap::fill operation and the signature will be invalid
156                out.push_str("\r\n");
157            } else if key == "h" {
158                // header lists can be rather long, and we want to control
159                // how they wrap with a bit more nuance. We'll put these
160                // on a line of their own, and explicitly wrap the value
161                out.push_str("\r\n");
162                value_storage = textwrap::fill(
163                    value,
164                    textwrap::Options::new(75)
165                        .initial_indent("")
166                        .line_ending(textwrap::LineEnding::CRLF)
167                        .word_separator(textwrap::WordSeparator::Custom(|line| {
168                            let mut start = 0;
169                            let mut prev_was_colon = false;
170                            let mut char_indices = line.char_indices();
171
172                            Box::new(std::iter::from_fn(move || {
173                                for (idx, ch) in char_indices.by_ref() {
174                                    if ch == ':' {
175                                        prev_was_colon = true;
176                                    } else if prev_was_colon {
177                                        prev_was_colon = false;
178                                        let word = Word::from(&line[start..idx]);
179                                        start = idx;
180
181                                        return Some(word);
182                                    }
183                                }
184                                if start < line.len() {
185                                    let word = Word::from(&line[start..]);
186                                    start = line.len();
187                                    return Some(word);
188                                }
189                                None
190                            }))
191                        }))
192                        .word_splitter(textwrap::WordSplitter::NoHyphenation)
193                        .subsequent_indent("\t"),
194                );
195                value = &value_storage;
196            } else {
197                out.push(' ');
198            }
199        }
200        out.push_str(key);
201        out.push('=');
202        out.push_str(value);
203        out.push(';');
204    }
205    textwrap::fill(
206        &out,
207        textwrap::Options::new(75)
208            .initial_indent("")
209            .line_ending(textwrap::LineEnding::CRLF)
210            .word_separator(textwrap::WordSeparator::AsciiSpace)
211            .word_splitter(textwrap::WordSplitter::NoHyphenation)
212            .subsequent_indent("\t"),
213    )
214}
215
216#[derive(Clone)]
217pub(crate) struct DKIMHeaderBuilder {
218    header: DKIMHeader,
219    time: Option<chrono::DateTime<chrono::offset::Utc>>,
220}
221impl DKIMHeaderBuilder {
222    pub(crate) fn new() -> Self {
223        DKIMHeaderBuilder {
224            header: DKIMHeader {
225                tags: IndexMap::new(),
226                raw_bytes: "".to_owned(),
227            },
228            time: None,
229        }
230    }
231
232    pub(crate) fn add_tag(mut self, name: &str, value: &str) -> Self {
233        let tag = parser::Tag {
234            name: name.to_owned(),
235            value: value.to_owned(),
236            raw_value: value.to_owned(),
237        };
238        self.header.tags.insert(name.to_owned(), tag);
239
240        self
241    }
242
243    pub(crate) fn set_signed_headers(self, headers: &HeaderList) -> Self {
244        let value = headers.as_h_list();
245        self.add_tag("h", &value)
246    }
247
248    pub(crate) fn set_expiry(self, duration: chrono::Duration) -> Result<Self, DKIMError> {
249        let time = self.time.ok_or(DKIMError::BuilderError(
250            "DKIMHeaderBuilder: set_time must be called prior to calling set_expiry",
251        ))?;
252        let expiry = (time + duration).timestamp();
253        Ok(self.add_tag("x", &expiry.to_string()))
254    }
255
256    pub(crate) fn set_time(mut self, time: chrono::DateTime<chrono::offset::Utc>) -> Self {
257        self.time = Some(time);
258        self.add_tag("t", &time.timestamp().to_string())
259    }
260
261    pub(crate) fn build(mut self) -> DKIMHeader {
262        self.header.raw_bytes = serialize(self.header.clone());
263        self.header
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_dkim_header_builder() {
273        let header = DKIMHeaderBuilder::new()
274            .add_tag("v", "1")
275            .add_tag("a", "something")
276            .build();
277        k9::snapshot!(header.raw_bytes, "v=1; a=something;");
278    }
279
280    fn signed_header_list(headers: &[&str]) -> HeaderList {
281        HeaderList::new(headers.iter().map(|h| h.to_lowercase()).collect())
282    }
283
284    #[test]
285    fn test_dkim_header_builder_signed_headers() {
286        let header = DKIMHeaderBuilder::new()
287            .add_tag("v", "2")
288            .set_signed_headers(&signed_header_list(&["header1", "header2", "header3"]))
289            .build();
290        k9::snapshot!(
291            header.raw_bytes,
292            r#"
293v=2;\r
294\th=header1:header2:header3;
295"#
296        );
297    }
298
299    #[test]
300    fn test_dkim_header_builder_time() {
301        use chrono::TimeZone;
302
303        let time = chrono::Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 1).unwrap();
304
305        let header = DKIMHeaderBuilder::new()
306            .set_time(time)
307            .set_expiry(chrono::Duration::try_hours(3).expect("3 hours ok"))
308            .unwrap()
309            .build();
310        k9::snapshot!(header.raw_bytes, "t=1609459201; x=1609470001;");
311    }
312}