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    use mail_parser::MessageParser;
238
239    #[test]
240    fn from_parsed_classifies_dkim2_headers() {
241        let raw = concat!(
242            "DKIM2-Signature: i=1; m=1; t=1740000000; d=test4.dkim2.com; mf=PHNlbmRlckB0ZXN0NC5ka2ltMi5jb20+; rt=PHJlY2lwaWVudEBleGFtcGxlLmNvbT4=; s=ed25519:ed25519-sha256:651OFNp+DdgjeVHm1EaEmnpcP6L9PWJczuJ5Oo9dzWPf0xnVgDLcVu4IMmNgW8stVVocIt7MBd8aL0Gc/lCsAA==;\r\n",
243            "Message-Instance: m=1; h=sha256:WT8nqIyG8W1R78H1QT4oZdo1SKdQrY9JHQ4fMC+IXHU=:frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=;\r\n",
244            "From: sender@test4.dkim2.com\r\n",
245            "To: recipient@example.com\r\n",
246            "\r\n",
247            "body\r\n",
248        )
249        .as_bytes();
250
251        let from_parsed =
252            AuthenticatedMessage::from_parsed(&MessageParser::new().parse(raw).unwrap(), raw, true);
253        assert_eq!(from_parsed.dkim2_signatures.len(), 1);
254        assert_eq!(from_parsed.dkim2_instances.len(), 1);
255
256        assert_eq!(from_parsed, AuthenticatedMessage::parse(raw).unwrap());
257    }
258
259    #[test]
260    fn signature_parse_malformed_does_not_panic() {
261        let cases: &[&[u8]] = &[
262            b"",
263            b"garbage no equals",
264            b"i=; m=; t=; d=;",
265            b"i=1; m=1; t=0; d=example.com; mf=; s=sel",
266            b"i=1; m=1; t=0; d=example.com; mf=; s=",
267            b"i=1; m=1; t=0; d=example.com; mf=; s=,,",
268            b"i=1; m=1; t=0; d=example.com; mf=; s=sel:rsa-sha256:",
269        ];
270        for c in cases {
271            let _ = Signature::parse(c);
272        }
273    }
274
275    #[test]
276    fn signature_parse_nd_with_envelope_is_rejected() {
277        assert!(Signature::parse(b"nd=next.example; mf=a@b.com").is_err());
278    }
279
280    #[test]
281    fn header_classification_no_false_positive() {
282        let msg = b"DKIM2-Silly: foo\r\nMessage-Idle: bar\r\nFrom: a@b\r\n\r\nbody\r\n";
283        let parsed = AuthenticatedMessage::parse(msg).unwrap();
284        assert_eq!(parsed.dkim2_signatures.len(), 0);
285        assert_eq!(parsed.dkim2_instances.len(), 0);
286    }
287
288    #[test]
289    fn header_with_digit_parses() {
290        let msg = b"X-Test1-Header: v\r\nFrom: a@b\r\n\r\nbody\r\n";
291        let parsed = AuthenticatedMessage::parse(msg).unwrap();
292        assert_eq!(parsed.raw_parsed_headers().len(), 2);
293    }
294
295    #[test]
296    fn signature_parse_reordered_tags_and_multi_sset() {
297        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";
298        let sig = Signature::parse(v).unwrap();
299        assert_eq!(sig.i, 3);
300        assert_eq!(sig.m, 2);
301        assert_eq!(sig.s.len(), 2);
302    }
303
304    #[test]
305    fn message_instance_parse_huge_recipe_does_not_panic_on_parse() {
306        let json = br#"{"b":[{"c":[1,4294967295]}]}"#;
307        let b64 = mail_builder::encoders::base64::base64_encode(json).unwrap();
308        let mut hdr = b"m=2; h=sha256:QQ==:Qg==; r=".to_vec();
309        hdr.extend_from_slice(&b64);
310        let mi = MessageInstance::parse(&hdr).unwrap();
311        assert!(mi.recipe.is_some());
312    }
313
314    #[test]
315    fn signature_m_can_reach_u32_max() {
316        let v: &[u8] =
317            b"i=1; m=4294967295; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=donotmodify;";
318        let sig = Signature::parse(v).unwrap();
319        assert_eq!(sig.m, u32::MAX);
320    }
321
322    #[test]
323    fn signature_parse_decodes_null_reverse_path() {
324        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=PD4=; rt=Yg==; s=sel:rsa-sha256:QQ==;";
325        let sig = Signature::parse(v).unwrap();
326        if let ChainBinding::Envelope { mail_from, .. } = &sig.chain {
327            assert_eq!(mail_from, "<>");
328        } else {
329            panic!("expected envelope chain");
330        }
331    }
332
333    #[test]
334    fn nonce_over_64_chars_is_rejected() {
335        let long = "a".repeat(65);
336        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; n={long}; s=sel:rsa-sha256:QQ==;");
337        assert!(matches!(
338            Signature::parse(v.as_bytes()),
339            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
340        ));
341    }
342
343    #[test]
344    fn nonce_exactly_64_chars_is_accepted() {
345        let n = "a".repeat(64);
346        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; n={n}; s=sel:rsa-sha256:QQ==;");
347        let sig = Signature::parse(v.as_bytes()).unwrap();
348        assert_eq!(sig.n.as_deref(), Some(n.as_str()));
349    }
350
351    #[test]
352    fn duplicate_signature_tag_is_rejected() {
353        let v: &[u8] = b"i=1; i=2; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
354        assert!(matches!(
355            Signature::parse(v),
356            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
357        ));
358    }
359
360    #[test]
361    fn duplicate_signature_tag_case_insensitive_is_rejected() {
362        let v: &[u8] = b"i=1; m=1; M=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
363        assert!(Signature::parse(v).is_err());
364    }
365
366    #[test]
367    fn duplicate_message_instance_tag_is_rejected() {
368        let v: &[u8] = b"m=1; m=2; h=sha256:QQ==:Qg==;";
369        assert!(matches!(
370            MessageInstance::parse(v),
371            Err(Error::Dkim2(Dkim2Error::InstanceSyntax(_)))
372        ));
373    }
374
375    #[test]
376    fn missing_t_tag_is_rejected() {
377        let v: &[u8] = b"i=1; m=1; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
378        assert!(matches!(
379            Signature::parse(v),
380            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
381                tag: "t",
382                ..
383            }))
384        ));
385    }
386
387    #[test]
388    fn missing_s_tag_is_rejected() {
389        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==;";
390        assert!(matches!(
391            Signature::parse(v),
392            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
393                tag: "s",
394                ..
395            }))
396        ));
397    }
398
399    #[test]
400    fn nd_with_envelope_is_rejected_as_unexpected() {
401        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; nd=next.example; mf=YQ==; s=sel:rsa-sha256:QQ==;";
402        assert!(matches!(
403            Signature::parse(v),
404            Err(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
405                tag: "nd",
406                ..
407            }))
408        ));
409    }
410
411    #[test]
412    fn unknown_algorithm_in_s_is_ignored() {
413        let v: &[u8] =
414            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:,sel:ed25519-sha256:QQ==;";
415        let sig = Signature::parse(v).unwrap();
416        assert_eq!(sig.s.len(), 1);
417        assert_eq!(sig.s[0].selector, "sel");
418    }
419
420    #[test]
421    fn only_unknown_algorithms_yields_empty_s() {
422        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:;";
423        let sig = Signature::parse(v).unwrap();
424        assert!(sig.s.is_empty());
425    }
426
427    #[test]
428    fn mf_without_rt_is_rejected() {
429        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
430        assert!(matches!(
431            Signature::parse(v),
432            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
433                tag: "rt",
434                ..
435            }))
436        ));
437    }
438
439    #[test]
440    fn rt_without_mf_is_rejected() {
441        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; rt=Yg==; s=sel:rsa-sha256:QQ==;";
442        assert!(matches!(
443            Signature::parse(v),
444            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
445                tag: "mf",
446                ..
447            }))
448        ));
449    }
450
451    #[test]
452    fn missing_d_tag_is_rejected() {
453        let v: &[u8] = b"i=1; m=1; t=0; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;";
454        assert!(matches!(
455            Signature::parse(v),
456            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
457                tag: "d",
458                ..
459            }))
460        ));
461    }
462
463    #[test]
464    fn neither_nd_nor_envelope_is_rejected() {
465        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; s=sel:rsa-sha256:QQ==;";
466        assert!(matches!(
467            Signature::parse(v),
468            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
469                tag: "mf",
470                ..
471            }))
472        ));
473    }
474
475    #[test]
476    fn rsa_sha1_set_is_dropped() {
477        let v: &[u8] =
478            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==,sel2:ed25519-sha256:QQ==;";
479        let sig = Signature::parse(v).unwrap();
480        assert_eq!(sig.s.len(), 1);
481        assert_eq!(sig.s[0].selector, "sel2");
482    }
483
484    #[test]
485    fn only_rsa_sha1_yields_empty_s() {
486        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==;";
487        let sig = Signature::parse(v).unwrap();
488        assert!(sig.s.is_empty());
489    }
490
491    #[test]
492    fn feedhere_flag_is_parsed() {
493        let v: &[u8] =
494            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=feedback,feedhere;";
495        let sig = Signature::parse(v).unwrap();
496        assert_eq!(sig.flags, vec![Flag::Feedback, Flag::FeedHere]);
497    }
498}