Skip to main content

mail_auth/dkim/
verify.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//! DKIM1 signature verification (RFC 6376, Section 6), including RFC 6541
8//! ATPS checks and RFC 6651 failure report address discovery.
9
10use crate::SystemTime;
11use crate::dns::DnsCache;
12use crate::{
13    AuthenticatedMessage, DkimOutput, DkimResult, Error, MessageAuthenticator, Parameters,
14    dkim::{DomainKey, VerifySignature},
15    dns::cache::NoCache,
16    sampling::is_within_pct,
17};
18use crate::{DnsError, crypto::CryptoError};
19use encodify::base32;
20
21use super::{
22    AtpsRecord, DkimError, DkimReportRecord, HashAlgorithm, RR_DNS, RR_EXPIRATION, RR_OTHER,
23    RR_SIGNATURE, RR_VERIFICATION, Signature,
24};
25
26impl MessageAuthenticator {
27    /// Verifies every `DKIM-Signature` header of a parsed RFC 5322 message
28    /// (RFC 6376, Section 6).
29    ///
30    /// `params` is an [`AuthenticatedMessage`], or [`Parameters`] wrapping one
31    /// together with a DNS cache. Returns one [`DkimOutput`] per signature:
32    /// first a `neutral` output for each header rejected with a DKIM error
33    /// (see [`DkimOutput`]), then one per parsed signature in header order. A
34    /// message without signatures yields an empty vector; this function never
35    /// fails.
36    ///
37    /// For each well formed, unexpired signature whose body hash matches, it
38    /// looks up the key record, a TXT record at `<s>._domainkey.<d>`
39    /// ([`DomainKey`]). When the signature verifies and carries `atps=` equal
40    /// to the domain of a `From` address, it also looks up the RFC 6541 ATPS
41    /// record at `<d or base32(hash(d))>._atps.<atps>` ([`AtpsRecord`]); the
42    /// result of that lookup becomes the result of the signature and
43    /// [`DkimOutput::is_atps`] is set. Finally, for every signature with `r=y`
44    /// that did not pass, it looks up the RFC 6651 report record at
45    /// `_report._domainkey.<d>` ([`DkimReportRecord`]) to fill
46    /// [`DkimOutput::report_address`].
47    ///
48    /// Result variants:
49    ///
50    /// - [`DkimResult::Pass`]: the signature verified.
51    /// - [`DkimResult::Neutral`]: the header is malformed or unsupported, was
52    ///   ignored because of `l=` in strict mode, has expired, or its body hash
53    ///   does not match.
54    /// - [`DkimResult::Fail`]: the cryptographic check failed, or the `i=`
55    ///   domain does not match `d=` (honouring `t=s`).
56    /// - [`DkimResult::PermError`]: the key record is missing, invalid,
57    ///   revoked or of an unsupported type (also for a missing ATPS record).
58    /// - [`DkimResult::TempError`]: a DNS resolver error occurred.
59    /// - [`DkimResult::None`]: never returned by this function.
60    ///
61    /// # Example
62    ///
63    /// ```rust,no_run
64    /// use mail_auth::{AuthenticatedMessage, DkimResult, MessageAuthenticator};
65    ///
66    /// # async fn run(raw_message: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
67    /// let authenticator = MessageAuthenticator::new_system_conf()?;
68    /// let message = AuthenticatedMessage::parse(raw_message).ok_or("invalid message")?;
69    /// let results = authenticator.verify_dkim(&message).await;
70    /// let all_pass = results.iter().all(|output| output.result() == &DkimResult::Pass);
71    /// # Ok(())
72    /// # }
73    /// ```
74    #[inline(always)]
75    pub async fn verify_dkim<'x, C>(
76        &self,
77        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
78    ) -> Vec<DkimOutput<'x>>
79    where
80        C: DnsCache + 'x,
81    {
82        self.verify_dkim_(
83            params.into(),
84            SystemTime::now()
85                .duration_since(SystemTime::UNIX_EPOCH)
86                .map_or(0, |d| d.as_secs()),
87        )
88        .await
89    }
90
91    pub(crate) async fn verify_dkim_<'x, C>(
92        &self,
93        params: Parameters<'x, &'x AuthenticatedMessage<'x>, C>,
94        now: u64,
95    ) -> Vec<DkimOutput<'x>>
96    where
97        C: DnsCache,
98    {
99        let message = params.input;
100        let mut output = Vec::with_capacity(message.dkim_headers.len() + message.errors.len());
101        let mut report_requested = false;
102
103        for header in &message.errors {
104            if let Error::Dkim(_) = &header.header {
105                output.push(DkimOutput::neutral(header.header.clone()));
106            }
107        }
108
109        for header in &message.dkim_headers {
110            let signature = &header.header;
111            if signature.r {
112                report_requested = true;
113            }
114
115            if !(signature.x == 0 || (signature.x > signature.t && signature.x > now)) {
116                output.push(
117                    DkimOutput::neutral(Error::Dkim(DkimError::SignatureExpired))
118                        .with_signature(signature),
119                );
120                continue;
121            }
122
123            let ha = HashAlgorithm::from(signature.a);
124            let bh = &message
125                .body_hashes
126                .iter()
127                .find(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
128                .unwrap()
129                .3;
130
131            if bh != &signature.bh {
132                output.push(
133                    DkimOutput::neutral(Error::Dkim(DkimError::BodyHashMismatch))
134                        .with_signature(signature),
135                );
136                continue;
137            }
138
139            let record = match self
140                .txt_lookup::<DomainKey>(signature.domain_key(), params.txt_cache())
141                .await
142            {
143                Ok(record) => record,
144                Err(err) => {
145                    output.push(DkimOutput::dns_error(err).with_signature(signature));
146                    continue;
147                }
148            };
149
150            if !signature.validate_auid(&record) {
151                output.push(
152                    DkimOutput::fail(Error::Dkim(DkimError::AuidMismatch))
153                        .with_signature(signature),
154                );
155                continue;
156            }
157
158            let dkim_hdr_value = header.value.strip_signature();
159            let mut headers = message.signed_headers(&signature.h, header.name, &dkim_hdr_value);
160
161            if let Err(err) = record.verify(&mut headers, signature, signature.ch) {
162                output.push(DkimOutput::fail(err).with_signature(signature));
163                continue;
164            }
165
166            if let Some(atps) = &signature.atps {
167                let mut found = false;
168                for from in &message.from {
169                    if let Some((_, domain)) = from.rsplit_once('@')
170                        && domain.eq(atps)
171                    {
172                        found = true;
173                        break;
174                    }
175                }
176
177                if found {
178                    let mut query_domain = match &signature.atpsh {
179                        Some(algorithm) => {
180                            base32::STANDARD_NO_PAD.encode(algorithm.hash(signature.d.as_bytes()))
181                        }
182                        None => signature.d.to_string(),
183                    };
184                    query_domain.push_str("._atps.");
185                    query_domain.push_str(atps);
186                    query_domain.push('.');
187
188                    match self
189                        .txt_lookup::<AtpsRecord>(query_domain, params.txt_cache())
190                        .await
191                    {
192                        Ok(_) => {
193                            output.push(DkimOutput::pass().with_atps().with_signature(signature));
194                        }
195                        Err(err) => {
196                            output.push(
197                                DkimOutput::dns_error(err)
198                                    .with_atps()
199                                    .with_signature(signature),
200                            );
201                        }
202                    }
203                    continue;
204                }
205            }
206
207            output.push(DkimOutput::pass().with_signature(signature));
208        }
209
210        if report_requested {
211            for dkim in &mut output {
212                let signature = if let Some(signature) = &dkim.signature {
213                    if signature.r && dkim.result != DkimResult::Pass {
214                        signature
215                    } else {
216                        continue;
217                    }
218                } else {
219                    continue;
220                };
221
222                let record = if let Ok(record) = self
223                    .txt_lookup::<DkimReportRecord>(
224                        format!("_report._domainkey.{}.", signature.d),
225                        params.txt_cache(),
226                    )
227                    .await
228                {
229                    if is_within_pct(record.rp) {
230                        record
231                    } else {
232                        continue;
233                    }
234                } else {
235                    continue;
236                };
237
238                dkim.report = match &dkim.result() {
239                    DkimResult::Neutral(err)
240                    | DkimResult::Fail(err)
241                    | DkimResult::PermError(err)
242                    | DkimResult::TempError(err) => {
243                        let send_report = match err {
244                            Error::Crypto(CryptoError::Library(_))
245                            | Error::Io(_)
246                            | Error::Crypto(CryptoError::FailedVerification)
247                            | Error::Dkim(DkimError::BodyHashMismatch)
248                            | Error::Dkim(DkimError::AuidMismatch) => {
249                                (record.rr & RR_VERIFICATION) != 0
250                            }
251                            Error::Base64
252                            | Error::Dkim(DkimError::UnsupportedVersion)
253                            | Error::Dkim(DkimError::UnsupportedAlgorithm)
254                            | Error::Dkim(DkimError::UnsupportedCanonicalization)
255                            | Error::Dkim(DkimError::UnsupportedKeyType)
256                            | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
257                                (record.rr & RR_SIGNATURE) != 0
258                            }
259                            Error::Dkim(DkimError::SignatureExpired) => {
260                                (record.rr & RR_EXPIRATION) != 0
261                            }
262                            Error::Dns(DnsError::Resolver(_))
263                            | Error::Dns(DnsError::RecordNotFound(_))
264                            | Error::Dns(DnsError::InvalidRecordType)
265                            | Error::Parse
266                            | Error::Dkim(DkimError::PublicKeyRevoked) => (record.rr & RR_DNS) != 0,
267                            #[cfg(feature = "arc")]
268                            Error::Arc(_) => (record.rr & RR_OTHER) != 0,
269                            Error::MissingParameters
270                            | Error::NoHeadersFound
271                            | Error::Dkim(DkimError::BodyLengthTag)
272                            | Error::NotAligned
273                            | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
274                        };
275
276                        if send_report {
277                            format!("{}@{}", record.ra, signature.d).into()
278                        } else {
279                            None
280                        }
281                    }
282                    DkimResult::None | DkimResult::Pass => None,
283                };
284            }
285        }
286
287        output
288    }
289}
290
291impl<'x> AuthenticatedMessage<'x> {
292    /// Returns the canonicalized signed header fields of the first
293    /// `DKIM-Signature` whose `x=` tag is absent or later than its `t=` tag.
294    ///
295    /// The output is the header hash input of RFC 6376, Section 3.7: the
296    /// headers listed in `h=`, followed by the `DKIM-Signature` header itself
297    /// with an empty `b=` value, canonicalized with the signature's header
298    /// canonicalization. The current time is not checked. Returns `None`
299    /// when the message has no such signature.
300    pub fn canonicalized_dkim_headers(&self) -> Option<Vec<u8>> {
301        let header = self
302            .dkim_headers
303            .iter()
304            .find(|header| header.header.x == 0 || header.header.x > header.header.t)?;
305        let signature = &header.header;
306        let dkim_hdr_value = header.value.strip_signature();
307        let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
308        let mut data = Vec::with_capacity(256);
309        signature.ch.canonicalize_headers(headers, &mut data);
310        Some(data)
311    }
312
313    pub(crate) fn signed_headers<'z: 'x>(
314        &'z self,
315        headers: &'x [String],
316        dkim_hdr_name: &'x [u8],
317        dkim_hdr_value: &'x [u8],
318    ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
319        let mut last_header_pos: Vec<(&[u8], usize)> = Vec::with_capacity(headers.len());
320        headers
321            .iter()
322            .filter_map(move |h| {
323                let name = h.as_bytes();
324                let slot = match last_header_pos
325                    .iter()
326                    .position(|(lh, _)| lh.eq_ignore_ascii_case(name))
327                {
328                    Some(slot) => slot,
329                    None => {
330                        last_header_pos.push((name, 0));
331                        last_header_pos.len() - 1
332                    }
333                };
334                let header_pos = last_header_pos.get(slot).map_or(0, |(_, pos)| *pos);
335                let (next_pos, result) = match self
336                    .headers
337                    .iter()
338                    .rev()
339                    .enumerate()
340                    .skip(header_pos)
341                    .find(|(_, (mh, _))| name.eq_ignore_ascii_case(mh))
342                {
343                    Some((last_pos, result)) => (last_pos + 1, Some(*result)),
344                    None => (self.headers.len(), None),
345                };
346                if let Some((_, pos)) = last_header_pos.get_mut(slot) {
347                    *pos = next_pos;
348                }
349                result
350            })
351            .chain([(dkim_hdr_name, dkim_hdr_value)])
352    }
353}
354
355impl Signature {
356    pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
357        if self.i.is_empty() {
358            return true;
359        }
360
361        let auid_domain = self
362            .i
363            .split_once('@')
364            .map_or("", |(_, auid_domain)| auid_domain)
365            .as_bytes();
366        let domain = self.d.as_bytes();
367
368        match auid_domain
369            .len()
370            .checked_sub(domain.len())
371            .and_then(|split| auid_domain.split_at_checked(split))
372        {
373            Some((parent, suffix)) if suffix.eq_ignore_ascii_case(domain) => {
374                parent.is_empty() || (!record.requires_strict_identity() && parent.ends_with(b"."))
375            }
376            _ => false,
377        }
378    }
379}
380
381pub(crate) trait Verifier: Sized {
382    fn strip_signature(&self) -> Vec<u8>;
383}
384
385#[derive(Clone, Copy, PartialEq, Eq)]
386enum TagState {
387    Semicolon,
388    Tag,
389    Value,
390    Signature,
391}
392
393fn strip_tag_segment(
394    segment: &[u8],
395    terminated: bool,
396    state: TagState,
397    unsigned_dkim: &mut Vec<u8>,
398) -> TagState {
399    if state != TagState::Semicolon {
400        unsigned_dkim.extend_from_slice(segment);
401        return if terminated {
402            TagState::Semicolon
403        } else {
404            state
405        };
406    }
407
408    let tag = segment
409        .iter()
410        .position(|ch| !ch.is_ascii_whitespace())
411        .unwrap_or(segment.len());
412
413    if !matches!(segment.get(tag), Some(b'b' | b'B')) {
414        unsigned_dkim.extend_from_slice(segment);
415        return if terminated || tag == segment.len() {
416            TagState::Semicolon
417        } else {
418            TagState::Value
419        };
420    }
421
422    let after_tag = &segment[tag + 1..];
423    let equals = after_tag
424        .iter()
425        .position(|ch| !ch.is_ascii_whitespace())
426        .unwrap_or(after_tag.len());
427
428    if after_tag.get(equals) != Some(&b'=') {
429        unsigned_dkim.extend_from_slice(segment);
430        return if terminated {
431            TagState::Semicolon
432        } else if equals == after_tag.len() {
433            TagState::Tag
434        } else {
435            TagState::Value
436        };
437    }
438
439    unsigned_dkim.extend_from_slice(&segment[..tag + equals + 2]);
440    if terminated {
441        unsigned_dkim.push(b';');
442        TagState::Value
443    } else {
444        TagState::Signature
445    }
446}
447
448fn strip_tag_list(mut rest: &[u8], unsigned_dkim: &mut Vec<u8>) -> TagState {
449    let mut state = TagState::Semicolon;
450    loop {
451        match memchr::memchr(b';', rest) {
452            Some(position) => {
453                let (segment, tail) = rest.split_at(position + 1);
454                state = strip_tag_segment(segment, true, state, unsigned_dkim);
455                rest = tail;
456            }
457            None => return strip_tag_segment(rest, false, state, unsigned_dkim),
458        }
459    }
460}
461
462fn strip_trailing_byte(ch: u8, discard: bool, state: &mut TagState, unsigned_dkim: &mut Vec<u8>) {
463    if *state == TagState::Signature {
464        if ch == b';' {
465            unsigned_dkim.push(b';');
466            *state = TagState::Semicolon;
467        }
468        return;
469    }
470
471    match ch {
472        b'=' if *state == TagState::Tag => {
473            unsigned_dkim.push(ch);
474            *state = TagState::Signature;
475        }
476        b'b' | b'B' if *state == TagState::Semicolon => {
477            unsigned_dkim.push(ch);
478            *state = TagState::Tag;
479        }
480        b';' => {
481            unsigned_dkim.push(ch);
482            *state = TagState::Semicolon;
483        }
484        _ if discard => (),
485        _ => {
486            unsigned_dkim.push(ch);
487            if !ch.is_ascii_whitespace() {
488                *state = TagState::Value;
489            }
490        }
491    }
492}
493
494impl Verifier for &[u8] {
495    fn strip_signature(&self) -> Vec<u8> {
496        let mut unsigned_dkim = Vec::with_capacity(self.len());
497        let (head, tail) = match self.len() {
498            0 => return unsigned_dkim,
499            1 => self.split_at(0),
500            len => self.split_at(len - 2),
501        };
502
503        let mut state = strip_tag_list(head, &mut unsigned_dkim);
504        match tail {
505            [cr, lf] => {
506                strip_trailing_byte(*cr, *cr == b'\r', &mut state, &mut unsigned_dkim);
507                strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim);
508            }
509            [lf] => strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim),
510            _ => (),
511        }
512
513        unsigned_dkim
514    }
515}
516
517impl<'x> From<&'x AuthenticatedMessage<'x>>
518    for Parameters<'x, &'x AuthenticatedMessage<'x>, NoCache>
519{
520    fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
521        Parameters::new(params)
522    }
523}
524
525#[cfg(test)]
526#[allow(unused)]
527pub(crate) mod test {
528    use std::{
529        fs,
530        path::PathBuf,
531        time::{Duration, Instant},
532    };
533
534    use encodify::base32;
535    use mail_parser::MessageParser;
536
537    use crate::{
538        AuthenticatedMessage, DkimResult, MessageAuthenticator,
539        dkim::DomainKey,
540        dkim::{HashAlgorithm, Signature, verify::Verifier},
541        dns::cache::test::DummyCaches,
542        parse::TxtRecordParser,
543    };
544
545    #[test]
546    fn validate_auid() {
547        let strict = DomainKey::parse(
548            b"v=DKIM1; k=ed25519; t=s; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
549        )
550        .unwrap();
551        let relaxed =
552            DomainKey::parse(b"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
553                .unwrap();
554
555        for (auid, strict_expected, relaxed_expected) in [
556            ("", true, true),
557            ("@example.com", true, true),
558            ("john@example.com", true, true),
559            ("@EXAMPLE.com", true, true),
560            ("@sub.example.com", false, true),
561            ("john@deep.sub.example.com", false, true),
562            ("@example.com.evil", false, false),
563            ("@xexample.com", false, false),
564            ("@other.org", false, false),
565            ("john", false, false),
566            ("@", false, false),
567        ] {
568            let signature = Signature {
569                i: auid.to_string(),
570                d: "example.com".to_string(),
571                ..Default::default()
572            };
573            assert_eq!(
574                signature.validate_auid(&strict),
575                strict_expected,
576                "t=s {auid:?}"
577            );
578            assert_eq!(
579                signature.validate_auid(&relaxed),
580                relaxed_expected,
581                "{auid:?}"
582            );
583        }
584    }
585
586    #[tokio::test]
587    async fn dkim_retain_signatures_reports_rejected() {
588        let raw = concat!(
589            "DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s; h=From; ",
590            "bh=YWJj; b=YWJj\r\n",
591            "From: a@example.com\r\n\r\nbody\r\n"
592        );
593        let mut message = AuthenticatedMessage::parse(raw.as_bytes()).unwrap();
594        assert_eq!(message.dkim_signatures().len(), 1);
595        assert!(!message.has_dkim_errors());
596
597        message.retain_dkim_signatures(|_| Err(crate::dkim::DkimError::UnsupportedAlgorithm));
598        assert!(message.dkim_signatures().is_empty());
599        assert!(message.has_dkim_errors());
600
601        let outputs = MessageAuthenticator::new_system_conf()
602            .unwrap()
603            .verify_dkim(&message)
604            .await;
605        assert_eq!(outputs.len(), 1);
606        assert_eq!(
607            outputs[0].result(),
608            &DkimResult::Neutral(crate::Error::Dkim(
609                crate::dkim::DkimError::UnsupportedAlgorithm
610            ))
611        );
612    }
613
614    #[tokio::test]
615    async fn dkim_verify() {
616        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
617        test_dir.push("resources");
618        test_dir.push("dkim");
619        let resolver = MessageAuthenticator::new_system_conf().unwrap();
620
621        for file_name in fs::read_dir(&test_dir).unwrap() {
622            let file_name = file_name.unwrap().path();
623            /*if !file_name.to_str().unwrap().contains("002") {
624                continue;
625            }*/
626            println!("DKIM verifying {}", file_name.to_str().unwrap());
627
628            let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
629            let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
630            let caches = new_cache(dns_records);
631            let raw_message = raw_message.replace('\n', "\r\n");
632            let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
633            assert_eq!(
634                message,
635                AuthenticatedMessage::from_parsed(
636                    &MessageParser::new().parse(&raw_message).unwrap(),
637                    raw_message.as_bytes(),
638                    true
639                )
640            );
641
642            let dkim = resolver
643                .verify_dkim_(caches.parameters(&message), 1667843664)
644                .await;
645
646            assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
647        }
648    }
649
650    #[test]
651    fn dkim_strip_signature() {
652        for (value, stripped_value) in [
653            ("b=abc;h=From\r\n", "b=;h=From"),
654            ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
655            ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
656            ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
657        ] {
658            assert_eq!(
659                String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
660                stripped_value
661            );
662        }
663    }
664
665    #[test]
666    fn atps_query_label() {
667        for (domain, label) in [
668            ("one.example.net", "QSP4I4D24CRHOPDZ3O3ZIU2KSGS3X6Z6"),
669            ("two.example.net", "ZTZGRRV3F45A4U6HLDKBF3ZCOW4V2AJX"),
670        ] {
671            assert_eq!(
672                base32::STANDARD_NO_PAD.encode(HashAlgorithm::Sha1.hash(domain.as_bytes())),
673                label
674            );
675        }
676    }
677
678    pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
679        let caches = DummyCaches::new();
680        for (key, value) in dns_records
681            .split('\n')
682            .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
683        {
684            caches.txt_add(
685                format!("{key}."),
686                DomainKey::parse(value).unwrap(),
687                Instant::now() + Duration::new(3200, 0),
688            );
689        }
690
691        caches
692    }
693}