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