Skip to main content

mail_auth/dkim2/
parse.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{
8    ChainBinding, Dkim2Error, Flag, MessageHash, MessageInstance, Signature, SignatureValue,
9};
10use crate::{
11    Error,
12    common::{
13        crypto::{Algorithm, HashAlgorithm},
14        parse::TagParser,
15    },
16};
17use mail_parser::decoders::base64::base64_decode;
18
19const I: u64 = b'i' as u64;
20const M: u64 = b'm' as u64;
21const T: u64 = b't' as u64;
22const D: u64 = b'd' as u64;
23const S: u64 = b's' as u64;
24const H: u64 = b'h' as u64;
25const R: u64 = b'r' as u64;
26const N: u64 = b'n' as u64;
27const F: u64 = b'f' as u64;
28const MF: u64 = (b'm' as u64) | ((b'f' as u64) << 8);
29const RT: u64 = (b'r' as u64) | ((b't' as u64) << 8);
30const ND: u64 = (b'n' as u64) | ((b'd' as u64) << 8);
31
32impl Signature {
33    /// Parses a single DKIM2-Signature header value (without the field name).
34    #[allow(clippy::while_let_on_iterator)]
35    pub fn parse(header: &[u8]) -> crate::Result<Signature> {
36        let mut signature = Signature::default();
37        let mut mail_from: Option<String> = None;
38        let mut rcpt_to: Vec<String> = Vec::new();
39        let mut next_domain: Option<String> = None;
40        let mut has_envelope = false;
41        let mut seen: Vec<u64> = Vec::with_capacity(16);
42        let mut header = header.iter();
43
44        while let Some(key) = header.key() {
45            if key != u64::MAX {
46                if seen.contains(&key) {
47                    return Err(Error::Dkim2(Dkim2Error::SignatureSyntax(signature.i)));
48                }
49                seen.push(key);
50            }
51            match key {
52                I => signature.i = header.number().unwrap_or(0) as u32,
53                M => signature.m = header.number().unwrap_or(0) as u32,
54                T => signature.t = header.number().unwrap_or(0),
55                D => signature.d = header.text(true),
56                S => {
57                    signature.s = parse_signature_values(&header.text(false))
58                        .ok_or(Error::Dkim2(Dkim2Error::SignatureSyntax(signature.i)))?;
59                }
60                MF => {
61                    mail_from = Some(
62                        decode_b64_string(&header.text(false))
63                            .ok_or(Error::Dkim2(Dkim2Error::SignatureSyntax(signature.i)))?,
64                    );
65                    has_envelope = true;
66                }
67                RT => {
68                    let value = header.text(false);
69                    rcpt_to = value
70                        .split(',')
71                        .map(|v| {
72                            decode_b64_string(v)
73                                .ok_or(Error::Dkim2(Dkim2Error::SignatureSyntax(signature.i)))
74                        })
75                        .collect::<Result<_, _>>()?;
76                    has_envelope = true;
77                }
78                ND => next_domain = Some(header.text(true)),
79                N => {
80                    let nonce = header.text(false);
81                    if nonce.len() > 64 {
82                        return Err(Error::Dkim2(Dkim2Error::SignatureSyntax(signature.i)));
83                    }
84                    signature.n = Some(nonce);
85                }
86                F => {
87                    signature.flags = header
88                        .text(false)
89                        .split(',')
90                        .filter(|f| !f.is_empty())
91                        .map(Flag::parse)
92                        .collect();
93                }
94                _ => header.ignore(),
95            }
96        }
97
98        for (key, tag) in [(I, "i"), (M, "m"), (T, "t"), (D, "d"), (S, "s")] {
99            if !seen.contains(&key) {
100                return Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
101                    i: signature.i,
102                    tag,
103                }));
104            }
105        }
106
107        signature.chain = match (next_domain, has_envelope) {
108            (Some(_), true) => {
109                return Err(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
110                    i: signature.i,
111                    tag: "nd",
112                }));
113            }
114            (Some(domain), false) => ChainBinding::NextDomain(domain),
115            (None, _) => {
116                for (key, tag) in [(MF, "mf"), (RT, "rt")] {
117                    if !seen.contains(&key) {
118                        return Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
119                            i: signature.i,
120                            tag,
121                        }));
122                    }
123                }
124                ChainBinding::Envelope {
125                    mail_from: mail_from.unwrap_or_default(),
126                    rcpt_to,
127                }
128            }
129        };
130
131        Ok(signature)
132    }
133}
134
135impl MessageInstance {
136    /// Parses a single Message-Instance header value (without the field name).
137    #[allow(clippy::while_let_on_iterator)]
138    pub fn parse(header: &[u8]) -> crate::Result<MessageInstance> {
139        let mut instance = MessageInstance::default();
140        let mut seen: Vec<u64> = Vec::with_capacity(4);
141        let mut header = header.iter();
142
143        while let Some(key) = header.key() {
144            if key != u64::MAX {
145                if seen.contains(&key) {
146                    return Err(Error::Dkim2(Dkim2Error::InstanceSyntax(instance.m)));
147                }
148                seen.push(key);
149            }
150            match key {
151                M => instance.m = header.number().unwrap_or(0) as u32,
152                H => {
153                    instance.hashes = parse_hashes(&header.text(false))
154                        .ok_or(Error::Dkim2(Dkim2Error::InstanceSyntax(instance.m)))?;
155                }
156                R => {
157                    let encoded = header.text(false);
158                    let json = base64_decode(encoded.as_bytes()).ok_or(Error::Base64)?;
159                    instance.recipe = Some(super::recipe::Recipe::from_json(&json)?);
160                }
161                _ => header.ignore(),
162            }
163        }
164
165        Ok(instance)
166    }
167}
168
169fn decode_b64_string(value: &str) -> Option<String> {
170    base64_decode(value.as_bytes()).and_then(|bytes| String::from_utf8(bytes).ok())
171}
172
173fn parse_signature_values(value: &str) -> Option<Vec<SignatureValue>> {
174    let value = value.trim();
175    if value.is_empty() {
176        return None;
177    }
178    let mut values = Vec::new();
179    for set in value.split(',') {
180        let mut parts = set.splitn(3, ':');
181        let selector = parts.next()?.trim();
182        let algorithm = parts.next()?.trim();
183        let signature = parts.next()?.trim();
184        let Some(algorithm) = Algorithm::parse(algorithm.as_bytes()) else {
185            continue;
186        };
187        if matches!(algorithm, Algorithm::RsaSha1) {
188            continue;
189        }
190        let b = if signature.is_empty() {
191            Vec::new()
192        } else {
193            base64_decode(signature.as_bytes())?
194        };
195        values.push(SignatureValue {
196            selector: selector.to_string(),
197            a: algorithm,
198            b,
199        });
200    }
201    Some(values)
202}
203
204fn parse_hashes(value: &str) -> Option<Vec<MessageHash>> {
205    let mut hashes = Vec::new();
206    for set in value.split(',') {
207        let mut parts = set.splitn(3, ':');
208        let name = parts.next()?.trim();
209        let header_hash = base64_decode(parts.next()?.trim().as_bytes())?;
210        let body_hash = base64_decode(parts.next()?.trim().as_bytes())?;
211        hashes.push(MessageHash {
212            name: HashAlgorithm::parse(name),
213            header_hash,
214            body_hash,
215        });
216    }
217    Some(hashes)
218}
219
220impl Flag {
221    pub fn parse(value: &str) -> Flag {
222        hashify::tiny_map!(value.as_bytes(),
223            b"donotmodify" => Flag::DoNotModify,
224            b"donotexplode" => Flag::DoNotExplode,
225            b"feedback" => Flag::Feedback,
226            b"feedhere" => Flag::FeedHere,
227            b"exploded" => Flag::Exploded,
228        )
229        .unwrap_or_else(|| Flag::Unknown(value.to_string()))
230    }
231}
232
233#[cfg(test)]
234mod test {
235    use super::*;
236    use crate::AuthenticatedMessage;
237
238    #[test]
239    fn signature_parse_malformed_does_not_panic() {
240        let cases: &[&[u8]] = &[
241            b"",
242            b"garbage no equals",
243            b"i=; m=; t=; d=;",
244            b"i=1; m=1; t=0; d=example.com; mf=; s=sel",
245            b"i=1; m=1; t=0; d=example.com; mf=; s=",
246            b"i=1; m=1; t=0; d=example.com; mf=; s=,,",
247            b"i=1; m=1; t=0; d=example.com; mf=; s=sel:rsa-sha256:",
248        ];
249        for c in cases {
250            let _ = Signature::parse(c);
251        }
252    }
253
254    #[test]
255    fn signature_parse_nd_with_envelope_is_rejected() {
256        assert!(Signature::parse(b"nd=next.example; mf=a@b.com").is_err());
257    }
258
259    #[test]
260    fn header_classification_no_false_positive() {
261        let msg = b"DKIM2-Silly: foo\r\nMessage-Idle: bar\r\nFrom: a@b\r\n\r\nbody\r\n";
262        let parsed = AuthenticatedMessage::parse(msg).unwrap();
263        assert_eq!(parsed.dkim2_signatures.len(), 0);
264        assert_eq!(parsed.dkim2_instances.len(), 0);
265    }
266
267    #[test]
268    fn header_with_digit_parses() {
269        let msg = b"X-Test1-Header: v\r\nFrom: a@b\r\n\r\nbody\r\n";
270        let parsed = AuthenticatedMessage::parse(msg).unwrap();
271        assert_eq!(parsed.raw_parsed_headers().len(), 2);
272    }
273
274    #[test]
275    fn signature_parse_reordered_tags_and_multi_sset() {
276        let v = b"f=donotmodify; m=2; s=sel:rsa-sha256:AAAA,sel2:ed25519-sha256:BBBB; i=3; d=ex.com; mf=; rt=Yg==; t=5; xunknown=zz";
277        let sig = Signature::parse(v).unwrap();
278        assert_eq!(sig.i, 3);
279        assert_eq!(sig.m, 2);
280        assert_eq!(sig.s.len(), 2);
281    }
282
283    #[test]
284    fn message_instance_parse_huge_recipe_does_not_panic_on_parse() {
285        let json = br#"{"b":[{"c":[1,4294967295]}]}"#;
286        let b64 = mail_builder::encoders::base64::base64_encode(json).unwrap();
287        let mut hdr = b"m=2; h=sha256:QQ==:Qg==; r=".to_vec();
288        hdr.extend_from_slice(&b64);
289        let mi = MessageInstance::parse(&hdr).unwrap();
290        assert!(mi.recipe.is_some());
291    }
292
293    #[test]
294    fn signature_m_can_reach_u32_max() {
295        let v: &[u8] =
296            b"i=1; m=4294967295; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=donotmodify;";
297        let sig = Signature::parse(v).unwrap();
298        assert_eq!(sig.m, u32::MAX);
299    }
300
301    #[test]
302    fn signature_parse_decodes_null_reverse_path() {
303        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=PD4=; rt=Yg==; s=sel:rsa-sha256:QQ==;";
304        let sig = Signature::parse(v).unwrap();
305        if let ChainBinding::Envelope { mail_from, .. } = &sig.chain {
306            assert_eq!(mail_from, "<>");
307        } else {
308            panic!("expected envelope chain");
309        }
310    }
311
312    #[test]
313    fn nonce_over_64_chars_is_rejected() {
314        let long = "a".repeat(65);
315        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; n={long}; s=sel:rsa-sha256:QQ==;");
316        assert!(matches!(
317            Signature::parse(v.as_bytes()),
318            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
319        ));
320    }
321
322    #[test]
323    fn nonce_exactly_64_chars_is_accepted() {
324        let n = "a".repeat(64);
325        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; n={n}; s=sel:rsa-sha256:QQ==;");
326        let sig = Signature::parse(v.as_bytes()).unwrap();
327        assert_eq!(sig.n.as_deref(), Some(n.as_str()));
328    }
329
330    #[test]
331    fn duplicate_signature_tag_is_rejected() {
332        let v: &[u8] = b"i=1; i=2; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
333        assert!(matches!(
334            Signature::parse(v),
335            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
336        ));
337    }
338
339    #[test]
340    fn duplicate_signature_tag_case_insensitive_is_rejected() {
341        let v: &[u8] = b"i=1; m=1; M=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
342        assert!(Signature::parse(v).is_err());
343    }
344
345    #[test]
346    fn duplicate_message_instance_tag_is_rejected() {
347        let v: &[u8] = b"m=1; m=2; h=sha256:QQ==:Qg==;";
348        assert!(matches!(
349            MessageInstance::parse(v),
350            Err(Error::Dkim2(Dkim2Error::InstanceSyntax(_)))
351        ));
352    }
353
354    #[test]
355    fn missing_t_tag_is_rejected() {
356        let v: &[u8] = b"i=1; m=1; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
357        assert!(matches!(
358            Signature::parse(v),
359            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
360                tag: "t",
361                ..
362            }))
363        ));
364    }
365
366    #[test]
367    fn missing_s_tag_is_rejected() {
368        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==;";
369        assert!(matches!(
370            Signature::parse(v),
371            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
372                tag: "s",
373                ..
374            }))
375        ));
376    }
377
378    #[test]
379    fn nd_with_envelope_is_rejected_as_unexpected() {
380        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; nd=next.example; mf=YQ==; s=sel:rsa-sha256:QQ==;";
381        assert!(matches!(
382            Signature::parse(v),
383            Err(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
384                tag: "nd",
385                ..
386            }))
387        ));
388    }
389
390    #[test]
391    fn unknown_algorithm_in_s_is_ignored() {
392        let v: &[u8] =
393            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:,sel:ed25519-sha256:QQ==;";
394        let sig = Signature::parse(v).unwrap();
395        assert_eq!(sig.s.len(), 1);
396        assert_eq!(sig.s[0].selector, "sel");
397    }
398
399    #[test]
400    fn only_unknown_algorithms_yields_empty_s() {
401        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:;";
402        let sig = Signature::parse(v).unwrap();
403        assert!(sig.s.is_empty());
404    }
405
406    #[test]
407    fn mf_without_rt_is_rejected() {
408        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
409        assert!(matches!(
410            Signature::parse(v),
411            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
412                tag: "rt",
413                ..
414            }))
415        ));
416    }
417
418    #[test]
419    fn rt_without_mf_is_rejected() {
420        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; rt=Yg==; s=sel:rsa-sha256:QQ==;";
421        assert!(matches!(
422            Signature::parse(v),
423            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
424                tag: "mf",
425                ..
426            }))
427        ));
428    }
429
430    #[test]
431    fn missing_d_tag_is_rejected() {
432        let v: &[u8] = b"i=1; m=1; t=0; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;";
433        assert!(matches!(
434            Signature::parse(v),
435            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
436                tag: "d",
437                ..
438            }))
439        ));
440    }
441
442    #[test]
443    fn neither_nd_nor_envelope_is_rejected() {
444        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; s=sel:rsa-sha256:QQ==;";
445        assert!(matches!(
446            Signature::parse(v),
447            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
448                tag: "mf",
449                ..
450            }))
451        ));
452    }
453
454    #[test]
455    fn rsa_sha1_set_is_dropped() {
456        let v: &[u8] =
457            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==,sel2:ed25519-sha256:QQ==;";
458        let sig = Signature::parse(v).unwrap();
459        assert_eq!(sig.s.len(), 1);
460        assert_eq!(sig.s[0].selector, "sel2");
461    }
462
463    #[test]
464    fn only_rsa_sha1_yields_empty_s() {
465        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==;";
466        let sig = Signature::parse(v).unwrap();
467        assert!(sig.s.is_empty());
468    }
469
470    #[test]
471    fn feedhere_flag_is_parsed() {
472        let v: &[u8] =
473            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=feedback,feedhere;";
474        let sig = Signature::parse(v).unwrap();
475        assert_eq!(sig.flags, vec![Flag::Feedback, Flag::FeedHere]);
476    }
477}