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
7//! Parsing of `DKIM2-Signature` (§10) and `Message-Instance` (§9) header
8//! field values, which are Tag=Value lists (§8).
9
10use super::{
11    ChainBinding, Dkim2Error, Dkim2Hash, Flag, HashName, MessageHash, MessageInstance, Signature,
12    SignatureValue, canonicalize::cmp_ignore_ascii_case, recipe::Recipe,
13};
14use crate::{Error, crypto::Algorithm};
15use encodify::base64;
16use memchr::memchr;
17use std::collections::HashSet;
18
19const MAX_NONCE_LEN: usize = 64;
20const MAX_LABEL_LEN: usize = 63;
21const MAX_DOMAIN_LEN: usize = 253;
22const MAX_ENTRIES_PER_ALGORITHM: usize = 2;
23const INLINE_SCAN_LIMIT: usize = 16;
24
25#[derive(Clone, Copy)]
26#[repr(u8)]
27enum SignatureTag {
28    I,
29    M,
30    T,
31    D,
32    S,
33    Mf,
34    Rt,
35    Nd,
36    N,
37    F,
38}
39
40#[derive(Clone, Copy)]
41#[repr(u8)]
42enum InstanceTag {
43    M,
44    H,
45    R,
46}
47
48impl SignatureTag {
49    fn parse(name: &[u8]) -> Option<Self> {
50        hashify::map!(name, SignatureTag,
51            b"i" => SignatureTag::I,
52            b"m" => SignatureTag::M,
53            b"t" => SignatureTag::T,
54            b"d" => SignatureTag::D,
55            b"s" => SignatureTag::S,
56            b"mf" => SignatureTag::Mf,
57            b"rt" => SignatureTag::Rt,
58            b"nd" => SignatureTag::Nd,
59            b"n" => SignatureTag::N,
60            b"f" => SignatureTag::F,
61        )
62        .copied()
63    }
64
65    fn bit(self) -> u16 {
66        1 << self as u16
67    }
68}
69
70impl InstanceTag {
71    fn parse(name: &[u8]) -> Option<Self> {
72        hashify::map!(name, InstanceTag,
73            b"m" => InstanceTag::M,
74            b"h" => InstanceTag::H,
75            b"r" => InstanceTag::R,
76        )
77        .copied()
78    }
79
80    fn bit(self) -> u16 {
81        1 << self as u16
82    }
83}
84
85#[derive(Default)]
86struct SeenTags<'x> {
87    known: u16,
88    unknown: Vec<&'x [u8]>,
89    unknown_set: HashSet<&'x [u8]>,
90}
91
92impl<'x> SeenTags<'x> {
93    fn insert_known(&mut self, bit: u16) -> bool {
94        let fresh = self.known & bit == 0;
95        self.known |= bit;
96        fresh
97    }
98
99    fn contains_known(&self, bit: u16) -> bool {
100        self.known & bit != 0
101    }
102
103    fn insert_unknown(&mut self, name: &'x [u8]) -> bool {
104        if !self.unknown_set.is_empty() {
105            return self.unknown_set.insert(name);
106        } else if self.unknown.contains(&name) {
107            return false;
108        }
109        self.unknown.push(name);
110        if self.unknown.len() > INLINE_SCAN_LIMIT {
111            self.unknown_set.extend(self.unknown.drain(..));
112        }
113        true
114    }
115}
116
117#[derive(Debug, Clone, Copy)]
118enum ParseError {
119    Syntax,
120    TagMissing(&'static str),
121    TagUnexpected(&'static str),
122    DuplicateSelector,
123    TooManySelectors,
124    DuplicateHash,
125    InvalidJson,
126}
127
128impl ParseError {
129    #[cold]
130    fn into_signature_error(self, header: &[u8]) -> Error {
131        let i = tag_number(header, b"i");
132        Error::Dkim2(match self {
133            ParseError::TagMissing(tag) => Dkim2Error::SignatureTagMissing { i, tag },
134            ParseError::TagUnexpected(tag) => Dkim2Error::SignatureTagUnexpected { i, tag },
135            ParseError::DuplicateSelector => Dkim2Error::DuplicateSelector(i),
136            ParseError::TooManySelectors => Dkim2Error::TooManySelectors(i),
137            ParseError::Syntax | ParseError::DuplicateHash | ParseError::InvalidJson => {
138                Dkim2Error::SignatureSyntax(i)
139            }
140        })
141    }
142
143    #[cold]
144    fn into_instance_error(self, header: &[u8]) -> Error {
145        let m = tag_number(header, b"m");
146        Error::Dkim2(match self {
147            ParseError::TagMissing(tag) => Dkim2Error::InstanceTagMissing { m, tag },
148            ParseError::DuplicateHash => Dkim2Error::InstanceDuplicateHash(m),
149            ParseError::InvalidJson => Dkim2Error::InvalidJson(m),
150            ParseError::Syntax
151            | ParseError::TagUnexpected(_)
152            | ParseError::DuplicateSelector
153            | ParseError::TooManySelectors => Dkim2Error::InstanceSyntax(m),
154        })
155    }
156}
157
158impl Signature {
159    /// Parses a single `DKIM2-Signature` header field value (without the
160    /// field name).
161    ///
162    /// The value is a Tag=Value list (§8): tag names are case-sensitive,
163    /// unrecognized tags (including upper-case variants of known ones) are
164    /// ignored, and a repeated tag name invalidates the field. `s=` entries
165    /// with an algorithm this crate does not implement are dropped after the
166    /// selector checks, so `s` can be empty. `mf=` and `rt=` are
167    /// base64-decoded.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`Dkim2Error::SignatureSyntax`] for a malformed tag list or
172    /// value, a repeated tag or a nonce that is not up to 64 printable ASCII
173    /// characters; [`Dkim2Error::DuplicateSelector`] or
174    /// [`Dkim2Error::TooManySelectors`] when `s=` repeats a selector or has
175    /// more than two entries for one algorithm;
176    /// [`Dkim2Error::SignatureTagMissing`] if `i=`, `m=`, `t=`, `d=` or `s=`
177    /// is missing, or if neither `nd=` nor both `mf=` and `rt=` are present;
178    /// and [`Dkim2Error::SignatureTagUnexpected`] if `nd=` appears together
179    /// with `mf=` or `rt=`.
180    pub fn parse(header: &[u8]) -> crate::Result<Signature> {
181        parse_signature(header).map_err(|err| err.into_signature_error(header))
182    }
183}
184
185fn parse_signature(header: &[u8]) -> Result<Signature, ParseError> {
186    let mut signature = Signature::default();
187    let mut seen = SeenTags::default();
188    let mut mail_from = None;
189    let mut rcpt_to = None;
190    let mut next_domain = None;
191
192    for spec in tag_list(header) {
193        let (name, value) = spec.ok_or(ParseError::Syntax)?;
194        let Some(tag) = SignatureTag::parse(name) else {
195            if is_tag_value(value) && seen.insert_unknown(name) {
196                continue;
197            }
198            return Err(ParseError::Syntax);
199        };
200        if !seen.insert_known(tag.bit()) {
201            return Err(ParseError::Syntax);
202        }
203        match tag {
204            SignatureTag::I => signature.i = parse_number(value).ok_or(ParseError::Syntax)?,
205            SignatureTag::M => signature.m = parse_number(value).ok_or(ParseError::Syntax)?,
206            SignatureTag::T => signature.t = parse_number(value).ok_or(ParseError::Syntax)?,
207            SignatureTag::D => signature.d = parse_domain(value).ok_or(ParseError::Syntax)?,
208            SignatureTag::S => signature.s = parse_signature_values(value)?,
209            SignatureTag::Mf => mail_from = Some(decode_string(value)?),
210            SignatureTag::Rt => {
211                rcpt_to = Some(
212                    split_bytes(value, b',')
213                        .map(decode_string)
214                        .collect::<Result<Vec<_>, _>>()?,
215                );
216            }
217            SignatureTag::Nd => next_domain = Some(parse_domain(value).ok_or(ParseError::Syntax)?),
218            SignatureTag::N => signature.n = Some(parse_nonce(value).ok_or(ParseError::Syntax)?),
219            SignatureTag::F => signature.flags = parse_flags(value)?,
220        }
221    }
222
223    for (tag, name) in [
224        (SignatureTag::I, "i"),
225        (SignatureTag::M, "m"),
226        (SignatureTag::T, "t"),
227        (SignatureTag::D, "d"),
228        (SignatureTag::S, "s"),
229    ] {
230        if !seen.contains_known(tag.bit()) {
231            return Err(ParseError::TagMissing(name));
232        }
233    }
234
235    signature.chain = match (next_domain, mail_from, rcpt_to) {
236        (Some(domain), None, None) => ChainBinding::NextDomain(domain),
237        (Some(_), _, _) => return Err(ParseError::TagUnexpected("nd")),
238        (None, Some(mail_from), Some(rcpt_to)) => ChainBinding::Envelope { mail_from, rcpt_to },
239        (None, None, _) => return Err(ParseError::TagMissing("mf")),
240        (None, Some(_), None) => return Err(ParseError::TagMissing("rt")),
241    };
242
243    Ok(signature)
244}
245
246impl MessageInstance {
247    /// Parses a single `Message-Instance` header field value (without the
248    /// field name).
249    ///
250    /// The value is a Tag=Value list (§8) with the same rules as
251    /// [`Signature::parse`]. Hash names are case-sensitive; unsupported ones
252    /// are kept as [`HashName::Unknown`]. The `r=` recipe is base64-decoded
253    /// and parsed and validated as JSON (§5).
254    ///
255    /// # Errors
256    ///
257    /// Returns [`Dkim2Error::InstanceSyntax`] for a malformed tag list or
258    /// value or a repeated tag, [`Dkim2Error::InstanceTagMissing`] if `m=`
259    /// or `h=` is missing, [`Dkim2Error::InstanceDuplicateHash`] if `h=`
260    /// repeats a hash algorithm, and [`Dkim2Error::InvalidJson`] if the
261    /// recipe is not valid JSON or does not follow the recipe schema.
262    pub fn parse(header: &[u8]) -> crate::Result<MessageInstance> {
263        parse_instance(header).map_err(|err| err.into_instance_error(header))
264    }
265}
266
267fn parse_instance(header: &[u8]) -> Result<MessageInstance, ParseError> {
268    let mut instance = MessageInstance::default();
269    let mut seen = SeenTags::default();
270
271    for spec in tag_list(header) {
272        let (name, value) = spec.ok_or(ParseError::Syntax)?;
273        let Some(tag) = InstanceTag::parse(name) else {
274            if is_tag_value(value) && seen.insert_unknown(name) {
275                continue;
276            }
277            return Err(ParseError::Syntax);
278        };
279        if !seen.insert_known(tag.bit()) {
280            return Err(ParseError::Syntax);
281        }
282        match tag {
283            InstanceTag::M => instance.m = parse_number(value).ok_or(ParseError::Syntax)?,
284            InstanceTag::H => instance.hashes = parse_hashes(value)?,
285            InstanceTag::R => {
286                let json = base64::LENIENT
287                    .decode(value)
288                    .map_err(|_| ParseError::Syntax)?;
289                instance.recipe =
290                    Some(Recipe::from_json(&json).map_err(|_| ParseError::InvalidJson)?);
291            }
292        }
293    }
294
295    for (tag, name) in [(InstanceTag::M, "m"), (InstanceTag::H, "h")] {
296        if !seen.contains_known(tag.bit()) {
297            return Err(ParseError::TagMissing(name));
298        }
299    }
300
301    Ok(instance)
302}
303
304fn tag_list(header: &[u8]) -> impl Iterator<Item = Option<(&[u8], &[u8])>> {
305    split_bytes(header, b';')
306        .map(<[u8]>::trim_ascii)
307        .filter(|spec| !spec.is_empty())
308        .map(tag_spec)
309}
310
311fn split_bytes(value: &[u8], separator: u8) -> SplitBytes<'_> {
312    SplitBytes {
313        rest: Some(value),
314        separator,
315    }
316}
317
318struct SplitBytes<'x> {
319    rest: Option<&'x [u8]>,
320    separator: u8,
321}
322
323impl<'x> Iterator for SplitBytes<'x> {
324    type Item = &'x [u8];
325
326    fn next(&mut self) -> Option<Self::Item> {
327        let (part, rest) = split_at_byte(self.rest?, self.separator);
328        self.rest = rest;
329        Some(part)
330    }
331}
332
333fn split_at_byte(value: &[u8], separator: u8) -> (&[u8], Option<&[u8]>) {
334    match memchr(separator, value) {
335        Some(pos) => (
336            value.get(..pos).unwrap_or_default(),
337            Some(value.get(pos + 1..).unwrap_or_default()),
338        ),
339        None => (value, None),
340    }
341}
342
343fn tag_spec(spec: &[u8]) -> Option<(&[u8], &[u8])> {
344    let (name, value) = split_at_byte(spec, b'=');
345    let (name, value) = (name.trim_ascii(), value?.trim_ascii());
346    let (first, rest) = name.split_first()?;
347    (first.is_ascii_alphabetic()
348        && rest
349            .iter()
350            .all(|&ch| ch.is_ascii_alphanumeric() || ch == b'_'))
351    .then_some((name, value))
352}
353
354fn is_tag_value(value: &[u8]) -> bool {
355    value
356        .iter()
357        .all(|&ch| ch.is_ascii_graphic() || matches!(ch, b' ' | b'\t' | b'\r' | b'\n'))
358}
359
360#[cold]
361fn tag_number(header: &[u8], tag: &[u8]) -> u32 {
362    tag_list(header)
363        .flatten()
364        .find(|(name, _)| *name == tag)
365        .and_then(|(_, value)| parse_number(value))
366        .unwrap_or(0)
367}
368
369fn parse_number<T: TryFrom<u64>>(value: &[u8]) -> Option<T> {
370    if value.is_empty() {
371        return None;
372    }
373    value
374        .iter()
375        .try_fold(0u64, |acc, &ch| {
376            ch.is_ascii_digit()
377                .then(|| acc.checked_mul(10)?.checked_add(u64::from(ch - b'0')))
378                .flatten()
379        })
380        .and_then(|number| T::try_from(number).ok())
381}
382
383fn parse_domain(value: &[u8]) -> Option<String> {
384    is_dns_name(value).then(|| String::from_utf8_lossy(value).to_ascii_lowercase())
385}
386
387fn is_dns_name(value: &[u8]) -> bool {
388    !value.is_empty()
389        && value.len() <= MAX_DOMAIN_LEN
390        && split_bytes(value, b'.').all(|label| {
391            !label.is_empty()
392                && label.len() <= MAX_LABEL_LEN
393                && label
394                    .iter()
395                    .all(|&ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_'))
396        })
397}
398
399pub(crate) fn is_domain(value: &[u8]) -> bool {
400    !value.is_empty()
401        && value.len() <= MAX_DOMAIN_LEN
402        && split_bytes(value, b'.').all(|label| label.len() <= MAX_LABEL_LEN)
403        && split_bytes(value, b'.').all(|label| match label {
404            [first, middle @ .., last] => {
405                first.is_ascii_alphanumeric()
406                    && last.is_ascii_alphanumeric()
407                    && middle
408                        .iter()
409                        .all(|&ch| ch.is_ascii_alphanumeric() || ch == b'-')
410            }
411            [single] => single.is_ascii_alphanumeric(),
412            [] => false,
413        })
414}
415
416fn parse_nonce(value: &[u8]) -> Option<String> {
417    (value.len() <= MAX_NONCE_LEN && value.iter().all(u8::is_ascii_graphic))
418        .then(|| String::from_utf8_lossy(value).into_owned())
419}
420
421pub(crate) fn is_textstring(value: &[u8]) -> bool {
422    !value.is_empty()
423        && value
424            .iter()
425            .all(|&ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_'))
426}
427
428fn decode_string(value: &[u8]) -> Result<String, ParseError> {
429    base64::LENIENT
430        .decode_to_string(value)
431        .map_err(|_| ParseError::Syntax)
432}
433
434fn parse_flags(value: &[u8]) -> Result<Vec<Flag>, ParseError> {
435    split_bytes(value, b',')
436        .map(<[u8]>::trim_ascii)
437        .filter(|flag| !flag.is_empty())
438        .map(|flag| {
439            is_textstring(flag)
440                .then(|| Flag::parse_bytes(flag))
441                .ok_or(ParseError::Syntax)
442        })
443        .collect()
444}
445
446fn parse_signature_values(value: &[u8]) -> Result<Vec<SignatureValue>, ParseError> {
447    let mut values = Vec::with_capacity(2);
448    let mut inline: [(&[u8], &[u8]); INLINE_SCAN_LIMIT] = [(&[], &[]); INLINE_SCAN_LIMIT];
449    let mut spill = Vec::new();
450    let mut count = 0;
451    for set in split_bytes(value, b',') {
452        let (selector, algorithm, signature) = split_triple(set).ok_or(ParseError::Syntax)?;
453        if !is_dns_name(selector) || !is_textstring(algorithm) {
454            return Err(ParseError::Syntax);
455        }
456        match inline.get_mut(count) {
457            Some(slot) => *slot = (selector, algorithm),
458            None => {
459                if spill.is_empty() {
460                    spill.extend_from_slice(&inline);
461                }
462                spill.push((selector, algorithm));
463            }
464        }
465        count += 1;
466
467        let Some(algorithm) = Algorithm::parse_dkim2(algorithm) else {
468            continue;
469        };
470        let b = if signature.is_empty() {
471            Vec::new()
472        } else {
473            base64::LENIENT
474                .decode(signature)
475                .map_err(|_| ParseError::Syntax)?
476        };
477        values.push(SignatureValue {
478            selector: String::from_utf8_lossy(selector).into_owned(),
479            a: algorithm,
480            b,
481        });
482    }
483
484    if spill.is_empty() {
485        check_selectors_inline(inline.get(..count).unwrap_or_default())?;
486    } else {
487        check_selectors_sorted(&mut spill)?;
488    }
489    Ok(values)
490}
491
492fn check_selectors_inline(entries: &[(&[u8], &[u8])]) -> Result<(), ParseError> {
493    for (pos, (selector, algorithm)) in entries.iter().enumerate() {
494        let mut same_algorithm = 1;
495        for (other_selector, other_algorithm) in entries.iter().skip(pos + 1) {
496            if selector.eq_ignore_ascii_case(other_selector) {
497                return Err(ParseError::DuplicateSelector);
498            }
499            same_algorithm += usize::from(algorithm == other_algorithm);
500        }
501        if same_algorithm > MAX_ENTRIES_PER_ALGORITHM {
502            return Err(ParseError::TooManySelectors);
503        }
504    }
505    Ok(())
506}
507
508fn check_selectors_sorted(entries: &mut [(&[u8], &[u8])]) -> Result<(), ParseError> {
509    entries.sort_unstable_by(|(a, _), (b, _)| cmp_ignore_ascii_case(a, b));
510    if entries
511        .windows(2)
512        .any(|pair| matches!(pair, [(a, _), (b, _)] if a.eq_ignore_ascii_case(b)))
513    {
514        return Err(ParseError::DuplicateSelector);
515    }
516    entries.sort_unstable_by_key(|(_, algorithm)| *algorithm);
517    if entries.windows(MAX_ENTRIES_PER_ALGORITHM + 1).any(|run| {
518        run.iter()
519            .all(|(_, algorithm)| run.first().is_some_and(|(_, first)| first == algorithm))
520    }) {
521        return Err(ParseError::TooManySelectors);
522    }
523    Ok(())
524}
525
526fn parse_hashes(value: &[u8]) -> Result<Vec<MessageHash>, ParseError> {
527    let mut hashes = Vec::with_capacity(1);
528    for set in split_bytes(value, b',') {
529        let (name, header_hash, body_hash) = split_triple(set).ok_or(ParseError::Syntax)?;
530        if !is_textstring(name) {
531            return Err(ParseError::Syntax);
532        }
533        hashes.push(MessageHash {
534            name: Dkim2Hash::parse(name).map_or_else(
535                || HashName::Unknown(String::from_utf8_lossy(name).into()),
536                HashName::Known,
537            ),
538            header_hash: base64::LENIENT
539                .decode(header_hash)
540                .map_err(|_| ParseError::Syntax)?,
541            body_hash: base64::LENIENT
542                .decode(body_hash)
543                .map_err(|_| ParseError::Syntax)?,
544        });
545    }
546
547    let unique = if hashes.len() <= INLINE_SCAN_LIMIT {
548        hashes.iter().enumerate().all(|(pos, hash)| {
549            hashes
550                .iter()
551                .skip(pos + 1)
552                .all(|other| other.name.as_str() != hash.name.as_str())
553        })
554    } else {
555        let mut names = hashes
556            .iter()
557            .map(|hash| hash.name.as_str())
558            .collect::<Vec<_>>();
559        names.sort_unstable();
560        names.windows(2).all(|pair| pair.first() != pair.last())
561    };
562    if unique {
563        Ok(hashes)
564    } else {
565        Err(ParseError::DuplicateHash)
566    }
567}
568
569fn split_triple(set: &[u8]) -> Option<(&[u8], &[u8], &[u8])> {
570    let (first, rest) = split_at_byte(set, b':');
571    let (second, third) = split_at_byte(rest?, b':');
572    Some((first.trim_ascii(), second.trim_ascii(), third?.trim_ascii()))
573}
574
575impl Flag {
576    /// Parses one `f=` flag value. Flag names are case-sensitive;
577    /// unrecognized values become [`Flag::Unknown`].
578    pub fn parse(value: &str) -> Flag {
579        Flag::parse_bytes(value.as_bytes())
580    }
581
582    fn parse_bytes(value: &[u8]) -> Flag {
583        hashify::fnc_map!(value,
584            b"feedback" => Some(Flag::Feedback),
585            b"feedhere" => Some(Flag::FeedHere),
586            b"exploded" => Some(Flag::Exploded),
587            _ => None,
588        )
589        .unwrap_or_else(|| Flag::Unknown(String::from_utf8_lossy(value).into_owned()))
590    }
591}
592
593#[cfg(test)]
594mod test {
595    use super::*;
596    use crate::AuthenticatedMessage;
597    use crate::dkim2::HashName;
598    use mail_parser::MessageParser;
599
600    #[test]
601    fn from_parsed_classifies_dkim2_headers() {
602        let raw = concat!(
603            "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",
604            "Message-Instance: m=1; h=sha256:WT8nqIyG8W1R78H1QT4oZdo1SKdQrY9JHQ4fMC+IXHU=:frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=;\r\n",
605            "From: sender@test4.dkim2.com\r\n",
606            "To: recipient@example.com\r\n",
607            "\r\n",
608            "body\r\n",
609        )
610        .as_bytes();
611
612        let from_parsed =
613            AuthenticatedMessage::from_parsed(&MessageParser::new().parse(raw).unwrap(), raw, true);
614        assert_eq!(from_parsed.dkim2_signatures.len(), 1);
615        assert_eq!(from_parsed.dkim2_instances.len(), 1);
616
617        assert_eq!(from_parsed, AuthenticatedMessage::parse(raw).unwrap());
618    }
619
620    #[test]
621    fn signature_parse_malformed_does_not_panic() {
622        let cases: &[&[u8]] = &[
623            b"",
624            b"garbage no equals",
625            b"i=; m=; t=; d=;",
626            b"i=1; m=1; t=0; d=example.com; mf=; s=sel",
627            b"i=1; m=1; t=0; d=example.com; mf=; s=",
628            b"i=1; m=1; t=0; d=example.com; mf=; s=,,",
629            b"i=1; m=1; t=0; d=example.com; mf=; s=sel:rsa-sha256:",
630        ];
631        for c in cases {
632            let _ = Signature::parse(c);
633        }
634    }
635
636    #[test]
637    fn signature_parse_nd_with_envelope_is_rejected() {
638        assert!(Signature::parse(b"nd=next.example; mf=a@b.com").is_err());
639    }
640
641    #[test]
642    fn header_classification_no_false_positive() {
643        let msg = b"DKIM2-Silly: foo\r\nMessage-Idle: bar\r\nFrom: a@b\r\n\r\nbody\r\n";
644        let parsed = AuthenticatedMessage::parse(msg).unwrap();
645        assert_eq!(parsed.dkim2_signatures.len(), 0);
646        assert_eq!(parsed.dkim2_instances.len(), 0);
647    }
648
649    #[test]
650    fn header_with_digit_parses() {
651        let msg = b"X-Test1-Header: v\r\nFrom: a@b\r\n\r\nbody\r\n";
652        let parsed = AuthenticatedMessage::parse(msg).unwrap();
653        assert_eq!(parsed.headers().len(), 2);
654    }
655
656    #[test]
657    fn signature_parse_reordered_tags_and_multi_sset() {
658        let v = b"f=feedback; m=2; s=sel:rsa-sha256:AAAA,sel2:ed25519-sha256:BBBB; i=3; d=ex.com; mf=; rt=Yg==; t=5; xunknown=zz";
659        let sig = Signature::parse(v).unwrap();
660        assert_eq!(sig.i, 3);
661        assert_eq!(sig.m, 2);
662        assert_eq!(sig.s.len(), 2);
663    }
664
665    #[test]
666    fn message_instance_parse_huge_recipe_does_not_panic_on_parse() {
667        let json = br#"{"b":[{"c":[1,4294967295]}]}"#;
668        let mut hdr = b"m=2; h=sha256:QQ==:Qg==; r=".to_vec();
669        base64::STANDARD.encode_append(json, &mut hdr);
670        let mi = MessageInstance::parse(&hdr).unwrap();
671        assert!(mi.recipe.is_some());
672    }
673
674    #[test]
675    fn signature_m_can_reach_u32_max() {
676        let v: &[u8] =
677            b"i=1; m=4294967295; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=feedback;";
678        let sig = Signature::parse(v).unwrap();
679        assert_eq!(sig.m, u32::MAX);
680    }
681
682    #[test]
683    fn signature_parse_decodes_null_reverse_path() {
684        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=PD4=; rt=Yg==; s=sel:rsa-sha256:QQ==;";
685        let sig = Signature::parse(v).unwrap();
686        if let ChainBinding::Envelope { mail_from, .. } = &sig.chain {
687            assert_eq!(mail_from, "<>");
688        } else {
689            panic!("expected envelope chain");
690        }
691    }
692
693    #[test]
694    fn nonce_over_64_chars_is_rejected() {
695        let long = "a".repeat(65);
696        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; n={long}; s=sel:rsa-sha256:QQ==;");
697        assert!(matches!(
698            Signature::parse(v.as_bytes()),
699            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
700        ));
701    }
702
703    #[test]
704    fn nonce_exactly_64_chars_is_accepted() {
705        let n = "a".repeat(64);
706        let v = format!("i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; n={n}; s=sel:rsa-sha256:QQ==;");
707        let sig = Signature::parse(v.as_bytes()).unwrap();
708        assert_eq!(sig.n.as_deref(), Some(n.as_str()));
709    }
710
711    #[test]
712    fn duplicate_signature_tag_is_rejected() {
713        let v: &[u8] = b"i=1; i=2; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
714        assert!(matches!(
715            Signature::parse(v),
716            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(_)))
717        ));
718    }
719
720    // -07 §8: tag names are case-sensitive, so M= is an unknown tag and is ignored.
721    #[test]
722    fn upper_case_tags_are_unknown() {
723        let v: &[u8] = b"i=1; m=1; M=2; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;";
724        assert_eq!(Signature::parse(v).unwrap().m, 1);
725        assert!(matches!(
726            Signature::parse(b"I=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;"),
727            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
728                tag: "i",
729                ..
730            }))
731        ));
732    }
733
734    // Unknown tags are compared by their full name: a repeat invalidates the list, a shared prefix does not.
735    #[test]
736    fn unknown_tag_duplicates() {
737        let base = "i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;";
738        assert!(
739            Signature::parse(format!("{base} longunknowntag1=a; longunknowntag2=b").as_bytes())
740                .is_ok()
741        );
742        assert!(matches!(
743            Signature::parse(format!("{base} x_y=a; x_y=b").as_bytes()),
744            Err(Error::Dkim2(Dkim2Error::SignatureSyntax(1)))
745        ));
746        let many = (0..40).map(|n| format!("t{n}=v; ")).collect::<String>();
747        assert!(Signature::parse(format!("{base} {many}").as_bytes()).is_ok());
748        assert!(Signature::parse(format!("{base} {many} t7=v").as_bytes()).is_err());
749        assert!(Signature::parse(format!("{base} 1x=v").as_bytes()).is_err());
750        assert!(Signature::parse(format!("{base} novalue").as_bytes()).is_err());
751    }
752
753    #[test]
754    fn selector_rules() {
755        let sig = |s: &str| {
756            Signature::parse(
757                format!("i=7; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s={s};").as_bytes(),
758            )
759        };
760        assert!(matches!(
761            sig("a:rsa-sha256:QQ==,A:ed25519-sha256:QQ=="),
762            Err(Error::Dkim2(Dkim2Error::DuplicateSelector(7)))
763        ));
764        assert!(matches!(
765            sig("a:rsa-sha256:QQ==,b:rsa-sha256:QQ==,c:rsa-sha256:QQ=="),
766            Err(Error::Dkim2(Dkim2Error::TooManySelectors(7)))
767        ));
768        assert!(matches!(
769            sig("a:x-new:QQ==,b:x-new:QQ==,c:x-new:QQ==,d:ed25519-sha256:QQ=="),
770            Err(Error::Dkim2(Dkim2Error::TooManySelectors(7)))
771        ));
772        let many = (0..20)
773            .map(|n| format!("s{n}:alg{n}:QQ=="))
774            .collect::<Vec<_>>()
775            .join(",");
776        assert!(sig(&many).is_ok());
777        assert!(matches!(
778            sig(&format!("{many},S3:x:QQ==")),
779            Err(Error::Dkim2(Dkim2Error::DuplicateSelector(7)))
780        ));
781        assert!(matches!(
782            sig(&format!("{many},z:alg3:QQ==,y:alg3:QQ==")),
783            Err(Error::Dkim2(Dkim2Error::TooManySelectors(7)))
784        ));
785
786        let parsed = sig("a:mldsa44-ed25519-sha512:QQ==,b:RSA-SHA256:QQ==").unwrap();
787        assert_eq!(parsed.s.len(), 1);
788        assert_eq!(parsed.s[0].a, Algorithm::MlDsa44Ed25519Sha512);
789    }
790
791    #[test]
792    fn message_instance_rules() {
793        assert!(matches!(
794            MessageInstance::parse(b"h=sha256:QQ==:QQ==;"),
795            Err(Error::Dkim2(Dkim2Error::InstanceTagMissing {
796                m: 0,
797                tag: "m"
798            }))
799        ));
800        assert!(matches!(
801            MessageInstance::parse(b"m=3;"),
802            Err(Error::Dkim2(Dkim2Error::InstanceTagMissing {
803                m: 3,
804                tag: "h"
805            }))
806        ));
807        assert!(matches!(
808            MessageInstance::parse(b"m=3; h=sha256:QQ==:QQ==,sha256:Qg==:Qg==;"),
809            Err(Error::Dkim2(Dkim2Error::InstanceDuplicateHash(3)))
810        ));
811        let many = (0..20)
812            .map(|n| format!("h{n}:QQ==:QQ=="))
813            .collect::<Vec<_>>()
814            .join(",");
815        assert!(MessageInstance::parse(format!("m=3; h={many};").as_bytes()).is_ok());
816        assert!(matches!(
817            MessageInstance::parse(format!("m=3; h={many},h7:QQ==:QQ==;").as_bytes()),
818            Err(Error::Dkim2(Dkim2Error::InstanceDuplicateHash(3)))
819        ));
820        let instance =
821            MessageInstance::parse(b"m=3; h=sha512:QQ==:QQ==, SHA256:QQ==:QQ==, sha3:Qg==:Qg==;")
822                .unwrap();
823        assert_eq!(
824            instance
825                .hashes
826                .iter()
827                .map(|hash| hash.name.clone())
828                .collect::<Vec<_>>(),
829            vec![
830                HashName::Known(Dkim2Hash::Sha512),
831                HashName::Unknown("SHA256".into()),
832                HashName::Unknown("sha3".into()),
833            ]
834        );
835        let mut written = Vec::new();
836        instance.write_value(&mut written);
837        assert_eq!(MessageInstance::parse(&written).unwrap(), instance);
838    }
839
840    #[test]
841    fn nonce_and_flag_syntax() {
842        let sig = |extra: &str| {
843            Signature::parse(
844                format!(
845                    "i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; {extra}"
846                )
847                .as_bytes(),
848            )
849        };
850        assert!(sig("n=two words").is_err());
851        assert!(sig("n=caf\u{e9}").is_err());
852        assert_eq!(
853            sig("f=Exploded, feedhere").unwrap().flags,
854            vec![Flag::Unknown("Exploded".into()), Flag::FeedHere]
855        );
856        assert!(sig("f=bad flag").is_err());
857        let with_domain = |domain: &str| {
858            Signature::parse(
859                format!("i=1; m=1; t=0; d={domain}; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;")
860                    .as_bytes(),
861            )
862        };
863        let long_label = format!("{}.com", "a".repeat(64));
864        let long_name = ["abcdefghi"; 26].join(".");
865        assert!(with_domain(&format!("{}.com", "a".repeat(63))).is_ok());
866        for domain in [
867            "ex..com",
868            ".",
869            "ex.com.",
870            ".ex.com",
871            "e x.com",
872            "ex/com",
873            "ex.com\\",
874            &long_label,
875            &long_name,
876        ] {
877            assert!(with_domain(domain).is_err(), "{domain}");
878        }
879        for (domain, parsed) in [
880            ("Mail-1.Ex.com", "mail-1.ex.com"),
881            ("_dmarc_ish.ex.com", "_dmarc_ish.ex.com"),
882            ("-ex.com", "-ex.com"),
883        ] {
884            assert_eq!(with_domain(domain).unwrap().d, parsed);
885        }
886        assert_eq!(
887            Signature::parse(
888                b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=my_sel.2024:rsa-sha256:QQ==;"
889            )
890            .unwrap()
891            .s[0]
892                .selector,
893            "my_sel.2024"
894        );
895        assert!(
896            Signature::parse(b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=a..b:rsa-sha256:QQ==;")
897                .is_err()
898        );
899        assert!(sig("i=4294967296").is_err());
900    }
901
902    #[test]
903    fn duplicate_message_instance_tag_is_rejected() {
904        let v: &[u8] = b"m=1; m=2; h=sha256:QQ==:Qg==;";
905        assert!(matches!(
906            MessageInstance::parse(v),
907            Err(Error::Dkim2(Dkim2Error::InstanceSyntax(_)))
908        ));
909    }
910
911    #[test]
912    fn missing_t_tag_is_rejected() {
913        let v: &[u8] = b"i=1; m=1; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
914        assert!(matches!(
915            Signature::parse(v),
916            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
917                tag: "t",
918                ..
919            }))
920        ));
921    }
922
923    #[test]
924    fn missing_s_tag_is_rejected() {
925        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==;";
926        assert!(matches!(
927            Signature::parse(v),
928            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
929                tag: "s",
930                ..
931            }))
932        ));
933    }
934
935    #[test]
936    fn nd_with_envelope_is_rejected_as_unexpected() {
937        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; nd=next.example; mf=YQ==; s=sel:rsa-sha256:QQ==;";
938        assert!(matches!(
939            Signature::parse(v),
940            Err(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
941                tag: "nd",
942                ..
943            }))
944        ));
945    }
946
947    #[test]
948    fn unknown_algorithm_in_s_is_ignored() {
949        let v: &[u8] =
950            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:,sel:ed25519-sha256:QQ==;";
951        let sig = Signature::parse(v).unwrap();
952        assert_eq!(sig.s.len(), 1);
953        assert_eq!(sig.s[0].selector, "sel");
954    }
955
956    #[test]
957    fn only_unknown_algorithms_yields_empty_s() {
958        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=banana:banana:;";
959        let sig = Signature::parse(v).unwrap();
960        assert!(sig.s.is_empty());
961    }
962
963    #[test]
964    fn mf_without_rt_is_rejected() {
965        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; s=sel:rsa-sha256:QQ==;";
966        assert!(matches!(
967            Signature::parse(v),
968            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
969                tag: "rt",
970                ..
971            }))
972        ));
973    }
974
975    #[test]
976    fn rt_without_mf_is_rejected() {
977        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; rt=Yg==; s=sel:rsa-sha256:QQ==;";
978        assert!(matches!(
979            Signature::parse(v),
980            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
981                tag: "mf",
982                ..
983            }))
984        ));
985    }
986
987    #[test]
988    fn missing_d_tag_is_rejected() {
989        let v: &[u8] = b"i=1; m=1; t=0; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==;";
990        assert!(matches!(
991            Signature::parse(v),
992            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
993                tag: "d",
994                ..
995            }))
996        ));
997    }
998
999    #[test]
1000    fn neither_nd_nor_envelope_is_rejected() {
1001        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; s=sel:rsa-sha256:QQ==;";
1002        assert!(matches!(
1003            Signature::parse(v),
1004            Err(Error::Dkim2(Dkim2Error::SignatureTagMissing {
1005                tag: "mf",
1006                ..
1007            }))
1008        ));
1009    }
1010
1011    #[test]
1012    fn rsa_sha1_set_is_dropped() {
1013        let v: &[u8] =
1014            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==,sel2:ed25519-sha256:QQ==;";
1015        let sig = Signature::parse(v).unwrap();
1016        assert_eq!(sig.s.len(), 1);
1017        assert_eq!(sig.s[0].selector, "sel2");
1018    }
1019
1020    #[test]
1021    fn only_rsa_sha1_yields_empty_s() {
1022        let v: &[u8] = b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha1:QQ==;";
1023        let sig = Signature::parse(v).unwrap();
1024        assert!(sig.s.is_empty());
1025    }
1026
1027    #[test]
1028    fn feedhere_flag_is_parsed() {
1029        let v: &[u8] =
1030            b"i=1; m=1; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:rsa-sha256:QQ==; f=feedback,feedhere;";
1031        let sig = Signature::parse(v).unwrap();
1032        assert_eq!(sig.flags, vec![Flag::Feedback, Flag::FeedHere]);
1033    }
1034}