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