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