Skip to main content

mail_auth/dkim/
parse.rs

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