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