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 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 {
35 let version = header.get_required_tag("v");
36 if version != "1" {
37 return Err(DKIMError::IncompatibleVersion);
38 }
39 }
40
41 if let Some(user) = header.get_tag("i") {
44 let signing_domain = header.get_required_tag("d");
45 if !user.ends_with(&signing_domain) {
47 return Err(DKIMError::DomainMismatch);
48 }
49 }
50
51 {
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 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 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 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 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
139fn 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 out.push_str("\r\n");
157 } else if key == "h" {
158 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}