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