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::Crypto(CryptoError::MalformedPublicKey)
246                            | Error::Io(_)
247                            | Error::Crypto(CryptoError::FailedVerification)
248                            | Error::Dkim(DkimError::BodyHashMismatch)
249                            | Error::Dkim(DkimError::AuidMismatch) => {
250                                (record.rr & RR_VERIFICATION) != 0
251                            }
252                            Error::Base64
253                            | Error::Dkim(DkimError::UnsupportedVersion)
254                            | Error::Dkim(DkimError::UnsupportedAlgorithm)
255                            | Error::Dkim(DkimError::UnsupportedCanonicalization)
256                            | Error::Dkim(DkimError::UnsupportedKeyType)
257                            | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
258                                (record.rr & RR_SIGNATURE) != 0
259                            }
260                            Error::Dkim(DkimError::SignatureExpired) => {
261                                (record.rr & RR_EXPIRATION) != 0
262                            }
263                            Error::Dns(DnsError::Resolver(_))
264                            | Error::Dns(DnsError::RecordNotFound(_))
265                            | Error::Dns(DnsError::InvalidRecordType)
266                            | Error::Parse
267                            | Error::Dkim(DkimError::PublicKeyRevoked) => (record.rr & RR_DNS) != 0,
268                            #[cfg(feature = "arc")]
269                            Error::Arc(_) => (record.rr & RR_OTHER) != 0,
270                            Error::MissingParameters
271                            | Error::NoHeadersFound
272                            | Error::Dkim(DkimError::BodyLengthTag)
273                            | Error::NotAligned
274                            | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
275                        };
276
277                        if send_report {
278                            format!("{}@{}", record.ra, signature.d).into()
279                        } else {
280                            None
281                        }
282                    }
283                    DkimResult::None | DkimResult::Pass => None,
284                };
285            }
286        }
287
288        output
289    }
290}
291
292impl<'x> AuthenticatedMessage<'x> {
293    /// Returns the canonicalized signed header fields of the first
294    /// `DKIM-Signature` whose `x=` tag is absent or later than its `t=` tag.
295    ///
296    /// The output is the header hash input of RFC 6376, Section 3.7: the
297    /// headers listed in `h=`, followed by the `DKIM-Signature` header itself
298    /// with an empty `b=` value, canonicalized with the signature's header
299    /// canonicalization. The current time is not checked. Returns `None`
300    /// when the message has no such signature.
301    pub fn canonicalized_dkim_headers(&self) -> Option<Vec<u8>> {
302        let header = self
303            .dkim_headers
304            .iter()
305            .find(|header| header.header.x == 0 || header.header.x > header.header.t)?;
306        let signature = &header.header;
307        let dkim_hdr_value = header.value.strip_signature();
308        let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
309        let mut data = Vec::with_capacity(256);
310        signature.ch.canonicalize_headers(headers, &mut data);
311        Some(data)
312    }
313
314    pub(crate) fn signed_headers<'z: 'x>(
315        &'z self,
316        headers: &'x [String],
317        dkim_hdr_name: &'x [u8],
318        dkim_hdr_value: &'x [u8],
319    ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
320        let mut last_header_pos: Vec<(&[u8], usize)> = Vec::with_capacity(headers.len());
321        headers
322            .iter()
323            .filter_map(move |h| {
324                let name = h.as_bytes();
325                let slot = match last_header_pos
326                    .iter()
327                    .position(|(lh, _)| lh.eq_ignore_ascii_case(name))
328                {
329                    Some(slot) => slot,
330                    None => {
331                        last_header_pos.push((name, 0));
332                        last_header_pos.len() - 1
333                    }
334                };
335                let header_pos = last_header_pos.get(slot).map_or(0, |(_, pos)| *pos);
336                let (next_pos, result) = match self
337                    .headers
338                    .iter()
339                    .rev()
340                    .enumerate()
341                    .skip(header_pos)
342                    .find(|(_, (mh, _))| name.eq_ignore_ascii_case(mh))
343                {
344                    Some((last_pos, result)) => (last_pos + 1, Some(*result)),
345                    None => (self.headers.len(), None),
346                };
347                if let Some((_, pos)) = last_header_pos.get_mut(slot) {
348                    *pos = next_pos;
349                }
350                result
351            })
352            .chain([(dkim_hdr_name, dkim_hdr_value)])
353    }
354}
355
356impl Signature {
357    pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
358        if self.i.is_empty() {
359            return true;
360        }
361
362        let auid_domain = self
363            .i
364            .split_once('@')
365            .map_or("", |(_, auid_domain)| auid_domain)
366            .as_bytes();
367        let domain = self.d.as_bytes();
368
369        match auid_domain
370            .len()
371            .checked_sub(domain.len())
372            .and_then(|split| auid_domain.split_at_checked(split))
373        {
374            Some((parent, suffix)) if suffix.eq_ignore_ascii_case(domain) => {
375                parent.is_empty() || (!record.requires_strict_identity() && parent.ends_with(b"."))
376            }
377            _ => false,
378        }
379    }
380}
381
382pub(crate) trait Verifier: Sized {
383    fn strip_signature(&self) -> Vec<u8>;
384}
385
386#[derive(Clone, Copy, PartialEq, Eq)]
387enum TagState {
388    Semicolon,
389    Tag,
390    Value,
391    Signature,
392}
393
394fn strip_tag_segment(
395    segment: &[u8],
396    terminated: bool,
397    state: TagState,
398    unsigned_dkim: &mut Vec<u8>,
399) -> TagState {
400    if state != TagState::Semicolon {
401        unsigned_dkim.extend_from_slice(segment);
402        return if terminated {
403            TagState::Semicolon
404        } else {
405            state
406        };
407    }
408
409    let tag = segment
410        .iter()
411        .position(|ch| !ch.is_ascii_whitespace())
412        .unwrap_or(segment.len());
413
414    if !matches!(segment.get(tag), Some(b'b' | b'B')) {
415        unsigned_dkim.extend_from_slice(segment);
416        return if terminated || tag == segment.len() {
417            TagState::Semicolon
418        } else {
419            TagState::Value
420        };
421    }
422
423    let after_tag = &segment[tag + 1..];
424    let equals = after_tag
425        .iter()
426        .position(|ch| !ch.is_ascii_whitespace())
427        .unwrap_or(after_tag.len());
428
429    if after_tag.get(equals) != Some(&b'=') {
430        unsigned_dkim.extend_from_slice(segment);
431        return if terminated {
432            TagState::Semicolon
433        } else if equals == after_tag.len() {
434            TagState::Tag
435        } else {
436            TagState::Value
437        };
438    }
439
440    unsigned_dkim.extend_from_slice(&segment[..tag + equals + 2]);
441    if terminated {
442        unsigned_dkim.push(b';');
443        TagState::Value
444    } else {
445        TagState::Signature
446    }
447}
448
449fn strip_tag_list(mut rest: &[u8], unsigned_dkim: &mut Vec<u8>) -> TagState {
450    let mut state = TagState::Semicolon;
451    loop {
452        match memchr::memchr(b';', rest) {
453            Some(position) => {
454                let (segment, tail) = rest.split_at(position + 1);
455                state = strip_tag_segment(segment, true, state, unsigned_dkim);
456                rest = tail;
457            }
458            None => return strip_tag_segment(rest, false, state, unsigned_dkim),
459        }
460    }
461}
462
463fn strip_trailing_byte(ch: u8, discard: bool, state: &mut TagState, unsigned_dkim: &mut Vec<u8>) {
464    if *state == TagState::Signature {
465        if ch == b';' {
466            unsigned_dkim.push(b';');
467            *state = TagState::Semicolon;
468        }
469        return;
470    }
471
472    match ch {
473        b'=' if *state == TagState::Tag => {
474            unsigned_dkim.push(ch);
475            *state = TagState::Signature;
476        }
477        b'b' | b'B' if *state == TagState::Semicolon => {
478            unsigned_dkim.push(ch);
479            *state = TagState::Tag;
480        }
481        b';' => {
482            unsigned_dkim.push(ch);
483            *state = TagState::Semicolon;
484        }
485        _ if discard => (),
486        _ => {
487            unsigned_dkim.push(ch);
488            if !ch.is_ascii_whitespace() {
489                *state = TagState::Value;
490            }
491        }
492    }
493}
494
495impl Verifier for &[u8] {
496    fn strip_signature(&self) -> Vec<u8> {
497        let mut unsigned_dkim = Vec::with_capacity(self.len());
498        let (head, tail) = match self.len() {
499            0 => return unsigned_dkim,
500            1 => self.split_at(0),
501            len => self.split_at(len - 2),
502        };
503
504        let mut state = strip_tag_list(head, &mut unsigned_dkim);
505        match tail {
506            [cr, lf] => {
507                strip_trailing_byte(*cr, *cr == b'\r', &mut state, &mut unsigned_dkim);
508                strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim);
509            }
510            [lf] => strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim),
511            _ => (),
512        }
513
514        unsigned_dkim
515    }
516}
517
518impl<'x> From<&'x AuthenticatedMessage<'x>>
519    for Parameters<'x, &'x AuthenticatedMessage<'x>, NoCache>
520{
521    fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
522        Parameters::new(params)
523    }
524}
525
526#[cfg(test)]
527#[allow(unused)]
528pub(crate) mod test {
529    use std::{
530        fs,
531        path::PathBuf,
532        time::{Duration, Instant},
533    };
534
535    use encodify::base32;
536    use mail_parser::MessageParser;
537
538    use crate::{
539        AuthenticatedMessage, DkimResult, MessageAuthenticator,
540        dkim::DomainKey,
541        dkim::{HashAlgorithm, Signature, verify::Verifier},
542        dns::cache::test::DummyCaches,
543        parse::TxtRecordParser,
544    };
545
546    #[test]
547    fn validate_auid() {
548        let strict = DomainKey::parse(
549            b"v=DKIM1; k=ed25519; t=s; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
550        )
551        .unwrap();
552        let relaxed =
553            DomainKey::parse(b"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
554                .unwrap();
555
556        for (auid, strict_expected, relaxed_expected) in [
557            ("", true, true),
558            ("@example.com", true, true),
559            ("john@example.com", true, true),
560            ("@EXAMPLE.com", true, true),
561            ("@sub.example.com", false, true),
562            ("john@deep.sub.example.com", false, true),
563            ("@example.com.evil", false, false),
564            ("@xexample.com", false, false),
565            ("@other.org", false, false),
566            ("john", false, false),
567            ("@", false, false),
568        ] {
569            let signature = Signature {
570                i: auid.to_string(),
571                d: "example.com".to_string(),
572                ..Default::default()
573            };
574            assert_eq!(
575                signature.validate_auid(&strict),
576                strict_expected,
577                "t=s {auid:?}"
578            );
579            assert_eq!(
580                signature.validate_auid(&relaxed),
581                relaxed_expected,
582                "{auid:?}"
583            );
584        }
585    }
586
587    #[tokio::test]
588    async fn dkim_retain_signatures_reports_rejected() {
589        let raw = concat!(
590            "DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s; h=From; ",
591            "bh=YWJj; b=YWJj\r\n",
592            "From: a@example.com\r\n\r\nbody\r\n"
593        );
594        let mut message = AuthenticatedMessage::parse(raw.as_bytes()).unwrap();
595        assert_eq!(message.dkim_signatures().len(), 1);
596        assert!(!message.has_dkim_errors());
597
598        message.retain_dkim_signatures(|_| Err(crate::dkim::DkimError::UnsupportedAlgorithm));
599        assert!(message.dkim_signatures().is_empty());
600        assert!(message.has_dkim_errors());
601
602        let outputs = MessageAuthenticator::new_system_conf()
603            .unwrap()
604            .verify_dkim(&message)
605            .await;
606        assert_eq!(outputs.len(), 1);
607        assert_eq!(
608            outputs[0].result(),
609            &DkimResult::Neutral(crate::Error::Dkim(
610                crate::dkim::DkimError::UnsupportedAlgorithm
611            ))
612        );
613    }
614
615    #[tokio::test]
616    async fn dkim_verify() {
617        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
618        test_dir.push("resources");
619        test_dir.push("dkim");
620        let resolver = MessageAuthenticator::new_system_conf().unwrap();
621
622        for file_name in fs::read_dir(&test_dir).unwrap() {
623            let file_name = file_name.unwrap().path();
624            /*if !file_name.to_str().unwrap().contains("002") {
625                continue;
626            }*/
627            println!("DKIM verifying {}", file_name.to_str().unwrap());
628
629            let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
630            let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
631            let caches = new_cache(dns_records);
632            let raw_message = raw_message.replace('\n', "\r\n");
633            let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
634            assert_eq!(
635                message,
636                AuthenticatedMessage::from_parsed(
637                    &MessageParser::new().parse(&raw_message).unwrap(),
638                    raw_message.as_bytes(),
639                    true
640                )
641            );
642
643            let dkim = resolver
644                .verify_dkim_(caches.parameters(&message), 1667843664)
645                .await;
646
647            assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
648        }
649    }
650
651    #[test]
652    fn dkim_strip_signature() {
653        for (value, stripped_value) in [
654            ("b=abc;h=From\r\n", "b=;h=From"),
655            ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
656            ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
657            ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
658        ] {
659            assert_eq!(
660                String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
661                stripped_value
662            );
663        }
664    }
665
666    #[test]
667    fn atps_query_label() {
668        for (domain, label) in [
669            ("one.example.net", "QSP4I4D24CRHOPDZ3O3ZIU2KSGS3X6Z6"),
670            ("two.example.net", "ZTZGRRV3F45A4U6HLDKBF3ZCOW4V2AJX"),
671        ] {
672            assert_eq!(
673                base32::STANDARD_NO_PAD.encode(HashAlgorithm::Sha1.hash(domain.as_bytes())),
674                label
675            );
676        }
677    }
678
679    pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
680        let caches = DummyCaches::new();
681        for (key, value) in dns_records
682            .split('\n')
683            .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
684        {
685            caches.txt_add(
686                format!("{key}."),
687                DomainKey::parse(value).unwrap(),
688                Instant::now() + Duration::new(3200, 0),
689            );
690        }
691
692        caches
693    }
694}