Skip to main content

mail_auth/dkim/
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//! Parsers for DKIM1 `DKIM-Signature` headers and for the `_domainkey`,
8//! `_report._domainkey` and `_atps` TXT records.
9
10use super::{
11    Algorithm, AtpsRecord, Canonicalization, DkimError, DkimReportRecord, Flag, HashAlgorithm,
12    RR_DNS, RR_OTHER, RR_POLICY, Service, Signature,
13};
14use crate::DnsError;
15use crate::{
16    Error,
17    crypto::VerifyingKeyType,
18    dkim::DomainKey,
19    dkim::{
20        RR_EXPIRATION, RR_SIGNATURE, RR_UNKNOWN_TAG, RR_VERIFICATION, record::R_MULTIPLE_RECORDS,
21    },
22    parse::*,
23};
24use std::slice::Iter;
25
26const ATPSH: u64 = (b'a' as u64)
27    | ((b't' as u64) << 8)
28    | ((b'p' as u64) << 16)
29    | ((b's' as u64) << 24)
30    | ((b'h' as u64) << 32);
31const ATPS: u64 =
32    (b'a' as u64) | ((b't' as u64) << 8) | ((b'p' as u64) << 16) | ((b's' as u64) << 24);
33const NONE: u64 =
34    (b'n' as u64) | ((b'o' as u64) << 8) | ((b'n' as u64) << 16) | ((b'e' as u64) << 24);
35const SHA256: u64 = (b's' as u64)
36    | ((b'h' as u64) << 8)
37    | ((b'a' as u64) << 16)
38    | ((b'2' as u64) << 24)
39    | ((b'5' as u64) << 32)
40    | ((b'6' as u64) << 40);
41const SHA1: u64 =
42    (b's' as u64) | ((b'h' as u64) << 8) | ((b'a' as u64) << 16) | ((b'1' as u64) << 24);
43const RA: u64 = (b'r' as u64) | ((b'a' as u64) << 8);
44const RP: u64 = (b'r' as u64) | ((b'p' as u64) << 8);
45const RR: u64 = (b'r' as u64) | ((b'r' as u64) << 8);
46const RS: u64 = (b'r' as u64) | ((b's' as u64) << 8);
47const ALL: u64 = (b'a' as u64) | ((b'l' as u64) << 8) | ((b'l' as u64) << 16);
48
49impl Signature {
50    /// Parses the value of a `DKIM-Signature` header (RFC 6376, Section 3.5).
51    ///
52    /// `header` is the raw header value, after the colon. Unknown tags are
53    /// ignored. When `c=` is absent, `simple/simple` is assumed. An `atpsh=`
54    /// value other than `sha1`, `sha256` or `none` clears the ATPS domain so
55    /// that no ATPS check can succeed.
56    ///
57    /// # Errors
58    ///
59    /// - [`DkimError::UnsupportedVersion`] when `v=` is present and not `1`.
60    /// - [`DkimError::UnsupportedAlgorithm`] for an unknown `a=` value.
61    /// - [`DkimError::UnsupportedCanonicalization`] for an unknown `c=` value.
62    /// - [`Error::Base64`] when `b=` or `bh=` is not valid base64.
63    /// - [`Error::Parse`] when `i=` is not valid quoted printable.
64    /// - [`Error::MissingParameters`] when any of `d=`, `s=`, `b=`, `bh=` or
65    ///   `h=` is missing or empty.
66    #[allow(clippy::while_let_on_iterator)]
67    pub fn parse(header: &'_ [u8]) -> crate::Result<Self> {
68        let mut signature = Signature {
69            v: 0,
70            a: Algorithm::RsaSha256,
71            d: "".into(),
72            s: "".into(),
73            i: "".into(),
74            b: Vec::with_capacity(0),
75            bh: Vec::with_capacity(0),
76            h: Vec::with_capacity(0),
77            z: Vec::with_capacity(0),
78            l: 0,
79            x: 0,
80            t: 0,
81            ch: Canonicalization::Simple,
82            cb: Canonicalization::Simple,
83            r: false,
84            atps: None,
85            atpsh: None,
86        };
87        let mut header = header.iter();
88
89        while let Some(key) = header.key() {
90            match key {
91                V => {
92                    signature.v = header.number().unwrap_or(0) as u32;
93                    if signature.v != 1 {
94                        return Err(Error::Dkim(DkimError::UnsupportedVersion));
95                    }
96                }
97                A => {
98                    signature.a = header.algorithm()?;
99                }
100                B => signature.b = header.base64().ok_or(Error::Base64)?,
101                BH => signature.bh = header.base64().ok_or(Error::Base64)?,
102                C => {
103                    let (ch, cb) = header.canonicalization(Canonicalization::Simple)?;
104                    signature.ch = ch;
105                    signature.cb = cb;
106                }
107                D => signature.d = header.text(true),
108                H => signature.h = header.items(),
109                I => signature.i = header.text_qp(true).ok_or(Error::Parse)?,
110                L => signature.l = header.number().unwrap_or(0),
111                S => signature.s = header.text(true),
112                T => signature.t = header.number().unwrap_or(0),
113                X => signature.x = header.number().unwrap_or(0),
114                Z => signature.z = header.headers_qp(),
115                R => signature.r = header.value() == Y,
116                ATPS => {
117                    if signature.atps.is_none() {
118                        signature.atps = Some(header.text(true));
119                    }
120                }
121                ATPSH => {
122                    signature.atpsh = match header.value() {
123                        SHA256 => HashAlgorithm::Sha256.into(),
124                        SHA1 => HashAlgorithm::Sha1.into(),
125                        NONE => None,
126                        _ => {
127                            signature.atps = Some("".into());
128                            None
129                        }
130                    };
131                }
132                _ => header.ignore(),
133            }
134        }
135
136        if !signature.d.is_empty()
137            && !signature.s.is_empty()
138            && !signature.b.is_empty()
139            && !signature.bh.is_empty()
140            && !signature.h.is_empty()
141        {
142            Ok(signature)
143        } else {
144            Err(Error::MissingParameters)
145        }
146    }
147}
148
149pub(crate) trait SignatureParser: Sized {
150    fn canonicalization(
151        &mut self,
152        default: Canonicalization,
153    ) -> crate::Result<(Canonicalization, Canonicalization)>;
154    fn algorithm(&mut self) -> crate::Result<Algorithm>;
155}
156
157impl SignatureParser for Iter<'_, u8> {
158    fn canonicalization(
159        &mut self,
160        default: Canonicalization,
161    ) -> crate::Result<(Canonicalization, Canonicalization)> {
162        let mut cb = default;
163        let mut ch = default;
164
165        let mut has_header = false;
166        let mut c = None;
167
168        while let Some(char) = self.next() {
169            match (char, c) {
170                (b's' | b'S', None) => {
171                    if self.match_bytes(b"imple") {
172                        c = Canonicalization::Simple.into();
173                    } else {
174                        return Err(Error::Dkim(DkimError::UnsupportedCanonicalization));
175                    }
176                }
177                (b'r' | b'R', None) => {
178                    if self.match_bytes(b"elaxed") {
179                        c = Canonicalization::Relaxed.into();
180                    } else {
181                        return Err(Error::Dkim(DkimError::UnsupportedCanonicalization));
182                    }
183                }
184                (b'/', Some(c_)) => {
185                    ch = c_;
186                    c = None;
187                    has_header = true;
188                }
189                (b';', _) => {
190                    break;
191                }
192                (_, _) => {
193                    if !char.is_ascii_whitespace() {
194                        return Err(Error::Dkim(DkimError::UnsupportedCanonicalization));
195                    }
196                }
197            }
198        }
199
200        if let Some(c) = c {
201            if has_header {
202                cb = c;
203            } else {
204                ch = c;
205            }
206        }
207
208        Ok((ch, cb))
209    }
210
211    fn algorithm(&mut self) -> crate::Result<Algorithm> {
212        match self.next_skip_whitespaces().unwrap_or(0) {
213            b'r' | b'R' => {
214                if self.match_bytes(b"sa-sha") {
215                    let mut algo = 0;
216
217                    for ch in self {
218                        match ch {
219                            b'1' if algo == 0 => algo = 1,
220                            b'2' if algo == 0 => algo = 2,
221                            b'5' if algo == 2 => algo = 25,
222                            b'6' if algo == 25 => algo = 256,
223                            b';' => {
224                                break;
225                            }
226                            _ => {
227                                if !ch.is_ascii_whitespace() {
228                                    return Err(Error::Dkim(DkimError::UnsupportedAlgorithm));
229                                }
230                            }
231                        }
232                    }
233
234                    match algo {
235                        256 => Ok(Algorithm::RsaSha256),
236                        1 => Ok(Algorithm::RsaSha1),
237                        _ => Err(Error::Dkim(DkimError::UnsupportedAlgorithm)),
238                    }
239                } else {
240                    Err(Error::Dkim(DkimError::UnsupportedAlgorithm))
241                }
242            }
243            b'e' | b'E' => {
244                if self.match_bytes(b"d25519-sha256") && self.seek_tag_end() {
245                    Ok(Algorithm::Ed25519Sha256)
246                } else {
247                    Err(Error::Dkim(DkimError::UnsupportedAlgorithm))
248                }
249            }
250            _ => Err(Error::Dkim(DkimError::UnsupportedAlgorithm)),
251        }
252    }
253}
254
255impl TxtRecordParser for DomainKey {
256    #[allow(clippy::while_let_on_iterator)]
257    fn parse(header: &[u8]) -> crate::Result<Self> {
258        let mut header = header.iter();
259        let mut flags = 0;
260        let mut key_type = VerifyingKeyType::Rsa;
261        let mut public_key = None;
262
263        while let Some(key) = header.key() {
264            match key {
265                V => {
266                    if !header.match_bytes(b"DKIM1") || !header.seek_tag_end() {
267                        return Err(Error::Dns(DnsError::InvalidRecordType));
268                    }
269                }
270                H => flags |= header.flags::<HashAlgorithm>(),
271                P => {
272                    if let Some(bytes) = header.base64() {
273                        public_key = Some(bytes);
274                    }
275                }
276                S => flags |= header.flags::<Service>(),
277                T => flags |= header.flags::<Flag>(),
278                K => {
279                    if let Some(ch) = header.next_skip_whitespaces() {
280                        match ch {
281                            b'r' | b'R' if header.match_bytes(b"sa") && header.seek_tag_end() => {
282                                key_type = VerifyingKeyType::Rsa;
283                            }
284                            b'e' | b'E'
285                                if header.match_bytes(b"d25519") && header.seek_tag_end() =>
286                            {
287                                key_type = VerifyingKeyType::Ed25519;
288                            }
289                            b'm' | b'M'
290                                if header.match_bytes(b"ldsa44-ed25519-sha512")
291                                    && header.seek_tag_end() =>
292                            {
293                                key_type = VerifyingKeyType::MlDsa44Ed25519;
294                            }
295                            b';' => (),
296                            _ => {
297                                return Err(Error::Dkim(DkimError::UnsupportedKeyType));
298                            }
299                        }
300                    }
301                }
302                _ => {
303                    header.ignore();
304                }
305            }
306        }
307
308        match public_key {
309            Some(public_key) if public_key.is_empty() => {
310                Err(Error::Dkim(DkimError::PublicKeyRevoked))
311            }
312            Some(public_key) => Ok(DomainKey {
313                p: key_type.verifying_key(&public_key)?,
314                f: flags,
315            }),
316            _ => Err(Error::Dns(DnsError::InvalidRecordType)),
317        }
318    }
319
320    fn parse_records(records: &[Vec<u8>]) -> crate::Result<Self> {
321        let mut result = Err(Error::Dns(DnsError::InvalidRecordType));
322        for record in records {
323            result = Self::parse(record);
324            if let Ok(key) = &mut result {
325                if records.len() > 1 {
326                    key.f |= R_MULTIPLE_RECORDS;
327                }
328                break;
329            }
330        }
331        result
332    }
333}
334
335impl TxtRecordParser for DkimReportRecord {
336    #[allow(clippy::while_let_on_iterator)]
337    fn parse(header: &[u8]) -> crate::Result<Self> {
338        let mut header = header.iter();
339        let mut record = DkimReportRecord {
340            ra: String::new(),
341            rp: 100,
342            rr: u8::MAX,
343            rs: None,
344        };
345
346        while let Some(key) = header.key() {
347            match key {
348                RA => {
349                    record.ra = header.text_qp(true).unwrap_or_default();
350                }
351                RP => {
352                    record.rp = std::cmp::min(header.number().unwrap_or(0), 100) as u8;
353                }
354                RS => {
355                    record.rs = header.text_qp(false);
356                }
357                RR => {
358                    record.rr = 0;
359                    loop {
360                        let (val, stop_char) = header.flag_value();
361                        match val {
362                            ALL => {
363                                record.rr = u8::MAX;
364                            }
365                            D => {
366                                record.rr |= RR_DNS;
367                            }
368                            O => {
369                                record.rr |= RR_OTHER;
370                            }
371                            P => {
372                                record.rr |= RR_POLICY;
373                            }
374                            S => {
375                                record.rr |= RR_SIGNATURE;
376                            }
377                            U => {
378                                record.rr |= RR_UNKNOWN_TAG;
379                            }
380                            V => {
381                                record.rr |= RR_VERIFICATION;
382                            }
383                            X => {
384                                record.rr |= RR_EXPIRATION;
385                            }
386                            _ => (),
387                        }
388
389                        if stop_char != b':' {
390                            break;
391                        }
392                    }
393                }
394
395                _ => {
396                    header.ignore();
397                }
398            }
399        }
400
401        if !record.ra.is_empty() {
402            Ok(record)
403        } else {
404            Err(Error::Dns(DnsError::InvalidRecordType))
405        }
406    }
407}
408
409impl TxtRecordParser for AtpsRecord {
410    #[allow(clippy::while_let_on_iterator)]
411    fn parse(header: &[u8]) -> crate::Result<Self> {
412        let mut header = header.iter();
413        let mut record = AtpsRecord { d: None };
414        let mut has_version = false;
415
416        while let Some(key) = header.key() {
417            match key {
418                V => {
419                    if !header.match_bytes(b"ATPS1") || !header.seek_tag_end() {
420                        return Err(Error::Dns(DnsError::InvalidRecordType));
421                    }
422                    has_version = true;
423                }
424                D => {
425                    record.d = header.text(true).into();
426                }
427                _ => {
428                    header.ignore();
429                }
430            }
431        }
432
433        if !has_version {
434            return Err(Error::Dns(DnsError::InvalidRecordType));
435        }
436
437        Ok(record)
438    }
439}
440
441impl ItemParser for HashAlgorithm {
442    fn parse(bytes: &[u8]) -> Option<Self> {
443        if bytes.eq_ignore_ascii_case(b"sha256") {
444            HashAlgorithm::Sha256.into()
445        } else if bytes.eq_ignore_ascii_case(b"sha1") {
446            HashAlgorithm::Sha1.into()
447        } else {
448            None
449        }
450    }
451}
452
453impl ItemParser for Flag {
454    fn parse(bytes: &[u8]) -> Option<Self> {
455        if bytes.eq_ignore_ascii_case(b"y") {
456            Flag::Testing.into()
457        } else if bytes.eq_ignore_ascii_case(b"s") {
458            Flag::MatchDomain.into()
459        } else {
460            None
461        }
462    }
463}
464
465impl ItemParser for Service {
466    fn parse(bytes: &[u8]) -> Option<Self> {
467        if bytes.eq(b"*") {
468            Service::All.into()
469        } else if bytes.eq_ignore_ascii_case(b"email") {
470            Service::Email.into()
471        } else {
472            None
473        }
474    }
475}
476
477#[cfg(test)]
478mod test {
479    use encodify::base64;
480
481    use crate::{
482        Error,
483        crypto::{Algorithm, R_HASH_SHA1, R_HASH_SHA256},
484        dkim::DomainKey,
485        dkim::{
486            Canonicalization, DkimReportRecord, RR_DNS, RR_EXPIRATION, RR_OTHER, RR_POLICY,
487            RR_SIGNATURE, RR_UNKNOWN_TAG, RR_VERIFICATION, Signature,
488            record::{R_FLAG_MATCH_DOMAIN, R_FLAG_TESTING, R_SVC_ALL, R_SVC_EMAIL},
489        },
490        headers::HeaderWriter,
491        parse::TxtRecordParser,
492    };
493
494    #[test]
495    fn dkim_signature_parse() {
496        for (signature, expected_result) in [
497            (
498                concat!(
499                    "v=1; a=rsa-sha256; s=default; d=stalw.art; c=relaxed/relaxed; ",
500                    "bh=QoiUNYyUV+1tZ/xUPRcE+gST2zAStvJx1OK078Ylm5s=; ",
501                    "b=Du0rvdzNodI6b5bhlUaZZ+gpXJi0VwjY/3qL7lS0wzKutNVCbvdJuZObGdAcv\n",
502                    " eVI/RNQh2gxW4H2ynMS3B+Unse1YLJQwdjuGxsCEKBqReKlsEKT8JlO/7b2AvxR\n",
503                    "\t9Q+M2aHD5kn9dbNIKnN/PKouutaXmm18QwL5EPEN9DHXSqQ=;",
504                    "h=Subject:To:From; t=311923920",
505                ),
506                Signature {
507                    v: 1,
508                    a: Algorithm::RsaSha256,
509                    d: "stalw.art".into(),
510                    s: "default".into(),
511                    i: "".into(),
512                    bh: base64::LENIENT
513                        .decode(b"QoiUNYyUV+1tZ/xUPRcE+gST2zAStvJx1OK078Ylm5s=")
514                        .unwrap(),
515                    b: base64::LENIENT
516                        .decode(
517                            concat!(
518                                "Du0rvdzNodI6b5bhlUaZZ+gpXJi0VwjY/3qL7lS0wzKutNVCbvdJuZObGdAcv",
519                                "eVI/RNQh2gxW4H2ynMS3B+Unse1YLJQwdjuGxsCEKBqReKlsEKT8JlO/7b2AvxR",
520                                "9Q+M2aHD5kn9dbNIKnN/PKouutaXmm18QwL5EPEN9DHXSqQ="
521                            )
522                            .as_bytes(),
523                        )
524                        .unwrap(),
525                    h: vec!["Subject".into(), "To".into(), "From".into()],
526                    z: vec![],
527                    l: 0,
528                    x: 0,
529                    t: 311923920,
530                    ch: Canonicalization::Relaxed,
531                    cb: Canonicalization::Relaxed,
532                    r: false,
533                    atps: None,
534                    atpsh: None,
535                },
536            ),
537            (
538                concat!(
539                    "v=1; a=rsa-sha1; d=example.net; s=brisbane;\r\n",
540                    " c=simple; q=dns/txt; i=@eng.example.net;\r\n",
541                    " t=1117574938; x=1118006938;\r\n",
542                    " h=from:to:subject:date;\r\n",
543                    " z=From:foo@eng.example.net|To:joe@example.com|\r\n",
544                    " Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;\r\n",
545                    " bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;\r\n",
546                    " b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR",
547                ),
548                Signature {
549                    v: 1,
550                    a: Algorithm::RsaSha1,
551                    d: "example.net".into(),
552                    s: "brisbane".into(),
553                    i: "@eng.example.net".into(),
554                    bh: base64::LENIENT
555                        .decode(b"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=")
556                        .unwrap(),
557                    b: base64::LENIENT
558                        .decode(
559                            concat!(
560                                "dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGe",
561                                "eruD00lszZVoG4ZHRNiYzR"
562                            )
563                            .as_bytes(),
564                        )
565                        .unwrap(),
566                    h: vec!["from".into(), "to".into(), "subject".into(), "date".into()],
567                    z: vec![
568                        "From:foo@eng.example.net".into(),
569                        "To:joe@example.com".into(),
570                        "Subject:demo run".into(),
571                        "Date:July 5, 2005 3:44:08 PM -0700".into(),
572                    ],
573                    l: 0,
574                    x: 1118006938,
575                    t: 1117574938,
576                    ch: Canonicalization::Simple,
577                    cb: Canonicalization::Simple,
578                    r: false,
579                    atps: None,
580                    atpsh: None,
581                },
582            ),
583            (
584                concat!(
585                    "v=1; a = rsa - sha256; s = brisbane; d = example.com;  \r\n",
586                    "c = simple / relaxed; q=dns/txt; i = \r\n joe=20@\r\n",
587                    " football.example.com; \r\n",
588                    "h=Received : From : To :\r\n Subject : : Date : Message-ID::;;;; \r\n",
589                    "bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; \r\n",
590                    "b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB \r\n",
591                    "4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut \r\n",
592                    "KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV \r\n",
593                    "4bmp/YzhwvcubU4=; l = 123",
594                ),
595                Signature {
596                    v: 1,
597                    a: Algorithm::RsaSha256,
598                    d: "example.com".into(),
599                    s: "brisbane".into(),
600                    i: "joe @football.example.com".into(),
601                    bh: base64::LENIENT
602                        .decode(b"2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=")
603                        .unwrap(),
604                    b: base64::LENIENT
605                        .decode(
606                            concat!(
607                                "AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB",
608                                "4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut",
609                                "KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV",
610                                "4bmp/YzhwvcubU4="
611                            )
612                            .as_bytes(),
613                        )
614                        .unwrap(),
615                    h: vec![
616                        "Received".into(),
617                        "From".into(),
618                        "To".into(),
619                        "Subject".into(),
620                        "Date".into(),
621                        "Message-ID".into(),
622                    ],
623                    z: vec![],
624                    l: 123,
625                    x: 0,
626                    t: 0,
627                    ch: Canonicalization::Simple,
628                    cb: Canonicalization::Relaxed,
629                    r: false,
630                    atps: None,
631                    atpsh: None,
632                },
633            ),
634        ] {
635            let result = Signature::parse(signature.as_bytes()).unwrap();
636            assert_eq!(result.v, expected_result.v, "{signature:?}");
637            assert_eq!(result.a, expected_result.a, "{signature:?}");
638            assert_eq!(result.d, expected_result.d, "{signature:?}");
639            assert_eq!(result.s, expected_result.s, "{signature:?}");
640            assert_eq!(result.i, expected_result.i, "{signature:?}");
641            assert_eq!(result.b, expected_result.b, "{signature:?}");
642            assert_eq!(result.bh, expected_result.bh, "{signature:?}");
643            assert_eq!(result.h, expected_result.h, "{signature:?}");
644            assert_eq!(result.z, expected_result.z, "{signature:?}");
645            assert_eq!(result.l, expected_result.l, "{signature:?}");
646            assert_eq!(result.x, expected_result.x, "{signature:?}");
647            assert_eq!(result.t, expected_result.t, "{signature:?}");
648            assert_eq!(result.ch, expected_result.ch, "{signature:?}");
649            assert_eq!(result.cb, expected_result.cb, "{signature:?}");
650
651            let header = result.to_header();
652            let value = header.strip_prefix("DKIM-Signature: ").unwrap();
653            let mut round_trip = Signature::parse(value.as_bytes()).unwrap();
654            round_trip.z = result.z.clone();
655            assert_eq!(round_trip, result, "{header:?}");
656        }
657    }
658
659    #[test]
660    fn dkim_signature_auid_qp() {
661        for (auid, expected) in [
662            (
663                "joe=40football.example.com",
664                Some("joe@football.example.com"),
665            ),
666            ("joe=3db@example.com", Some("joe=b@example.com")),
667            ("jo\r\n\te=\r\n\t40example.com", Some("joe@example.com")),
668            ("x=zz@evil.example", None),
669            ("joe=4@example.com", None),
670            ("joe@example.com=", None),
671        ] {
672            let header = format!(
673                "v=1; a=rsa-sha256; d=example.com; s=sel; h=from; i={auid}; bh=MTIz; b=dzdV;"
674            );
675            match (Signature::parse(header.as_bytes()), expected) {
676                (Ok(signature), Some(expected)) => assert_eq!(signature.i, expected, "{auid:?}"),
677                (Err(Error::Parse), None) => {}
678                (result, _) => panic!("{auid:?}: unexpected {result:?}"),
679            }
680        }
681    }
682
683    #[test]
684    fn dkim_record_parse() {
685        for (record, expected_result) in [
686            (
687                concat!(
688                    "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
689                    "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt",
690                    "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v",
691                    "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi",
692                    "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
693                ),
694                0,
695            ),
696            (
697                concat!(
698                    "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC",
699                    "AQ8AMIIBCgKCAQEAvzwKQIIWzQXv0nihasFTT3+JO23hXCg",
700                    "e+ESWNxCJdVLxKL5edxrumEU3DnrPeGD6q6E/vjoXwBabpm",
701                    "8F5o96MEPm7v12O5IIK7wx7gIJiQWvexwh+GJvW4aFFa0g1",
702                    "3Ai75UdZjGFNKHAEGeLmkQYybK/EHW5ymRlSg3g8zydJGEc",
703                    "I/melLCiBoShHjfZFJEThxLmPHNSi+KOUMypxqYHd7hzg6W",
704                    "7qnq6t9puZYXMWj6tEaf6ORWgb7DOXZSTJJjAJPBWa2+Urx",
705                    "XX6Ro7L7Xy1zzeYFCk8W5vmn0wMgGpjkWw0ljJWNwIpxZAj9",
706                    "p5wMedWasaPS74TZ1b7tI39ncp6QIDAQAB ; t= y : s :yy:x;",
707                    "s=*:email;; h= sha1:sha 256:other;; n=ignore these notes "
708                ),
709                R_HASH_SHA1
710                    | R_HASH_SHA256
711                    | R_SVC_ALL
712                    | R_SVC_EMAIL
713                    | R_FLAG_MATCH_DOMAIN
714                    | R_FLAG_TESTING,
715            ),
716            (
717                concat!(
718                    "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYtb/9Sh8nGKV7exhUFS",
719                    "+cBNXlHgO1CxD9zIfQd5ztlq1LO7g38dfmFpQafh9lKgqPBTolFhZxhF1yUNT",
720                    "hpV673NdAtaCVGNyx/fTYtvyyFe9DH2tmm/ijLlygDRboSkIJ4NHZjK++48hk",
721                    "NP8/htqWHS+CvwWT4Qgs0NtB7Re9bQIDAQAB"
722                ),
723                0,
724            ),
725        ] {
726            assert_eq!(
727                DomainKey::parse(record.as_bytes()).unwrap().f,
728                expected_result
729            );
730        }
731    }
732
733    #[test]
734    fn dkim_record_empty_p_is_revoked() {
735        use crate::{Error, dkim::DkimError};
736        for record in ["v=DKIM1; p=", "v=DKIM1; k=rsa; p=;", "p="] {
737            assert!(
738                matches!(
739                    DomainKey::parse(record.as_bytes()),
740                    Err(Error::Dkim(DkimError::PublicKeyRevoked))
741                ),
742                "{record:?}"
743            );
744        }
745    }
746
747    #[test]
748    fn dkim_report_record_parse() {
749        for (record, expected_result) in [
750            (
751                "ra=dkim-errors; rp=97; rr=v:x",
752                DkimReportRecord {
753                    ra: "dkim-errors".to_string(),
754                    rp: 97,
755                    rr: RR_VERIFICATION | RR_EXPIRATION,
756                    rs: None,
757                },
758            ),
759            (
760                "ra=postmaster; rp=1; rr=d:o:p:s:u:v:x; rs=Error=20Message;",
761                DkimReportRecord {
762                    ra: "postmaster".to_string(),
763                    rp: 1,
764                    rr: RR_DNS
765                        | RR_OTHER
766                        | RR_POLICY
767                        | RR_SIGNATURE
768                        | RR_UNKNOWN_TAG
769                        | RR_VERIFICATION
770                        | RR_EXPIRATION,
771                    rs: "Error Message".to_string().into(),
772                },
773            ),
774            (
775                "ra=dkim=2Derrors; rs=bad=zz;",
776                DkimReportRecord {
777                    ra: "dkim-errors".to_string(),
778                    rp: 100,
779                    rr: u8::MAX,
780                    rs: None,
781                },
782            ),
783        ] {
784            assert_eq!(
785                DkimReportRecord::parse(record.as_bytes()).unwrap(),
786                expected_result
787            );
788        }
789    }
790}