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