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
7use crate::SystemTime;
8use crate::{
9    AuthenticatedMessage, DkimOutput, DkimResult, Error, MX, MessageAuthenticator, Parameters,
10    RecordSet, ResolverCache, Txt,
11    common::{
12        base32::Base32Writer,
13        cache::NoCache,
14        headers::Writer,
15        verify::{DomainKey, VerifySignature},
16    },
17    is_within_pct,
18};
19use crate::{DnsError, common::crypto::CryptoError};
20use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
21
22use super::{
23    Atps, DkimError, DomainKeyReport, Flag, HashAlgorithm, RR_DNS, RR_EXPIRATION, RR_OTHER,
24    RR_SIGNATURE, RR_VERIFICATION, Signature,
25};
26
27impl MessageAuthenticator {
28    /// Verifies DKIM headers of an RFC5322 message.
29    #[inline(always)]
30    pub async fn verify_dkim<'x, TXT, MXX, IPV4, IPV6, PTR>(
31        &self,
32        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
33    ) -> Vec<DkimOutput<'x>>
34    where
35        TXT: ResolverCache<Box<str>, Txt> + 'x,
36        MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
37        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
38        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
39        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
40    {
41        self.verify_dkim_(
42            params.into(),
43            SystemTime::now()
44                .duration_since(SystemTime::UNIX_EPOCH)
45                .map_or(0, |d| d.as_secs()),
46        )
47        .await
48    }
49
50    pub(crate) async fn verify_dkim_<'x, TXT, MXX, IPV4, IPV6, PTR>(
51        &self,
52        params: Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>,
53        now: u64,
54    ) -> Vec<DkimOutput<'x>>
55    where
56        TXT: ResolverCache<Box<str>, Txt>,
57        MXX: ResolverCache<Box<str>, RecordSet<MX>>,
58        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>>,
59        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>>,
60        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>>,
61    {
62        let message = params.params;
63        let mut output = Vec::with_capacity(message.dkim_headers.len() + message.errors.len());
64        let mut report_requested = false;
65
66        // Surface malformed DKIM signatures
67        for header in &message.errors {
68            if let Error::Dkim(_) = &header.header {
69                output.push(DkimOutput::neutral(header.header.clone()));
70            }
71        }
72
73        // Validate DKIM headers
74        for header in &message.dkim_headers {
75            let signature = &header.header;
76            if signature.r {
77                report_requested = true;
78            }
79
80            if !(signature.x == 0 || (signature.x > signature.t && signature.x > now)) {
81                output.push(
82                    DkimOutput::neutral(Error::Dkim(DkimError::SignatureExpired))
83                        .with_signature(signature),
84                );
85                continue;
86            }
87
88            // Validate body hash
89            let ha = HashAlgorithm::from(signature.a);
90            let bh = &message
91                .body_hashes
92                .iter()
93                .find(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
94                .unwrap()
95                .3;
96
97            if bh != &signature.bh {
98                output.push(
99                    DkimOutput::neutral(Error::Dkim(DkimError::FailedBodyHashMatch))
100                        .with_signature(signature),
101                );
102                continue;
103            }
104
105            // Obtain ._domainkey TXT record
106            let record = match self
107                .txt_lookup::<DomainKey>(signature.domain_key(), params.cache_txt)
108                .await
109            {
110                Ok(record) => record,
111                Err(err) => {
112                    output.push(DkimOutput::dns_error(err).with_signature(signature));
113                    continue;
114                }
115            };
116
117            // Enforce t=s flag
118            if !signature.validate_auid(&record) {
119                output.push(
120                    DkimOutput::fail(Error::Dkim(DkimError::FailedAuidMatch))
121                        .with_signature(signature),
122                );
123                continue;
124            }
125
126            // Hash headers
127            let dkim_hdr_value = header.value.strip_signature();
128            let mut headers = message.signed_headers(&signature.h, header.name, &dkim_hdr_value);
129
130            // Verify signature
131            if let Err(err) = record.verify(&mut headers, signature, signature.ch) {
132                output.push(DkimOutput::fail(err).with_signature(signature));
133                continue;
134            }
135
136            // Verify third-party signature, if any.
137            if let Some(atps) = &signature.atps {
138                let mut found = false;
139                // RFC5322.From has to match atps=
140                for from in &message.from {
141                    if let Some((_, domain)) = from.rsplit_once('@')
142                        && domain.eq(atps)
143                    {
144                        found = true;
145                        break;
146                    }
147                }
148
149                if found {
150                    let mut query_domain = match &signature.atpsh {
151                        Some(algorithm) => {
152                            let mut writer = Base32Writer::with_capacity(40);
153                            let output = algorithm.hash(signature.d.as_bytes());
154                            writer.write(output.as_ref());
155                            writer.finalize()
156                        }
157                        None => signature.d.to_string(),
158                    };
159                    query_domain.push_str("._atps.");
160                    query_domain.push_str(atps);
161                    query_domain.push('.');
162
163                    match self
164                        .txt_lookup::<Atps>(query_domain, params.cache_txt)
165                        .await
166                    {
167                        Ok(_) => {
168                            // ATPS Verification successful
169                            output.push(DkimOutput::pass().with_atps().with_signature(signature));
170                        }
171                        Err(err) => {
172                            output.push(
173                                DkimOutput::dns_error(err)
174                                    .with_atps()
175                                    .with_signature(signature),
176                            );
177                        }
178                    }
179                    continue;
180                }
181            }
182
183            // Verification successful
184            output.push(DkimOutput::pass().with_signature(signature));
185        }
186
187        // Handle reports
188        if report_requested {
189            for dkim in &mut output {
190                // Process signatures with errors that requested reports
191                let signature = if let Some(signature) = &dkim.signature {
192                    if signature.r && dkim.result != DkimResult::Pass {
193                        signature
194                    } else {
195                        continue;
196                    }
197                } else {
198                    continue;
199                };
200
201                // Obtain ._domainkey TXT record
202                let record = if let Ok(record) = self
203                    .txt_lookup::<DomainKeyReport>(
204                        format!("_report._domainkey.{}.", signature.d),
205                        params.cache_txt,
206                    )
207                    .await
208                {
209                    if is_within_pct(record.rp) {
210                        record
211                    } else {
212                        continue;
213                    }
214                } else {
215                    continue;
216                };
217
218                // Set report address
219                dkim.report = match &dkim.result() {
220                    DkimResult::Neutral(err)
221                    | DkimResult::Fail(err)
222                    | DkimResult::PermError(err)
223                    | DkimResult::TempError(err) => {
224                        let send_report = match err {
225                            Error::Crypto(CryptoError::Library(_))
226                            | Error::Io(_)
227                            | Error::Crypto(CryptoError::FailedVerification)
228                            | Error::Dkim(DkimError::FailedBodyHashMatch)
229                            | Error::Dkim(DkimError::FailedAuidMatch) => {
230                                (record.rr & RR_VERIFICATION) != 0
231                            }
232                            Error::Base64
233                            | Error::Dkim(DkimError::UnsupportedVersion)
234                            | Error::Dkim(DkimError::UnsupportedAlgorithm)
235                            | Error::Dkim(DkimError::UnsupportedCanonicalization)
236                            | Error::Dkim(DkimError::UnsupportedKeyType)
237                            | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
238                                (record.rr & RR_SIGNATURE) != 0
239                            }
240                            Error::Dkim(DkimError::SignatureExpired) => {
241                                (record.rr & RR_EXPIRATION) != 0
242                            }
243                            Error::Dns(DnsError::Resolver(_))
244                            | Error::Dns(DnsError::RecordNotFound(_))
245                            | Error::Dns(DnsError::InvalidRecordType)
246                            | Error::ParseError
247                            | Error::Dkim(DkimError::RevokedPublicKey) => (record.rr & RR_DNS) != 0,
248                            #[cfg(feature = "arc")]
249                            Error::Arc(_) => (record.rr & RR_OTHER) != 0,
250                            Error::MissingParameters
251                            | Error::NoHeadersFound
252                            | Error::Dkim(DkimError::SignatureLength)
253                            | Error::NotAligned
254                            | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
255                        };
256
257                        if send_report {
258                            format!("{}@{}", record.ra, signature.d).into()
259                        } else {
260                            None
261                        }
262                    }
263                    DkimResult::None | DkimResult::Pass => None,
264                };
265            }
266        }
267
268        output
269    }
270}
271
272impl<'x> AuthenticatedMessage<'x> {
273    pub async fn get_canonicalized_header(&self) -> Result<Vec<u8>, Error> {
274        // Based on verify_dkim_ function
275        // Iterate through possible DKIM headers
276        let mut data = Vec::with_capacity(256);
277        for header in &self.dkim_headers {
278            // Ensure signature is not obviously invalid
279            let signature = &header.header;
280            if !(signature.x == 0 || (signature.x > signature.t)) {
281                continue;
282            }
283
284            // Get pre-hashed but canonically ordered headers, who's hash is signed
285            let dkim_hdr_value = header.value.strip_signature();
286            let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
287            signature.ch.canonicalize_headers(headers, &mut data);
288
289            return Ok(data);
290        }
291        // Return not ok
292        Err(Error::Dkim(DkimError::FailedBodyHashMatch))
293    }
294
295    pub fn signed_headers<'z: 'x>(
296        &'z self,
297        headers: &'x [String],
298        dkim_hdr_name: &'x [u8],
299        dkim_hdr_value: &'x [u8],
300    ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
301        let mut last_header_pos: Vec<(&[u8], usize)> = Vec::with_capacity(headers.len());
302        headers
303            .iter()
304            .filter_map(move |h| {
305                let name = h.as_bytes();
306                let slot = match last_header_pos
307                    .iter()
308                    .position(|(lh, _)| lh.eq_ignore_ascii_case(name))
309                {
310                    Some(slot) => slot,
311                    None => {
312                        last_header_pos.push((name, 0));
313                        last_header_pos.len() - 1
314                    }
315                };
316                let header_pos = last_header_pos.get(slot).map_or(0, |(_, pos)| *pos);
317                let (next_pos, result) = match self
318                    .headers
319                    .iter()
320                    .rev()
321                    .enumerate()
322                    .skip(header_pos)
323                    .find(|(_, (mh, _))| name.eq_ignore_ascii_case(mh))
324                {
325                    Some((last_pos, result)) => (last_pos + 1, Some(*result)),
326                    None => (self.headers.len(), None),
327                };
328                if let Some((_, pos)) = last_header_pos.get_mut(slot) {
329                    *pos = next_pos;
330                }
331                result
332            })
333            .chain([(dkim_hdr_name, dkim_hdr_value)])
334    }
335}
336
337impl Signature {
338    pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
339        if self.i.is_empty() {
340            return true;
341        }
342
343        let auid_domain = self
344            .i
345            .split_once('@')
346            .map_or("", |(_, auid_domain)| auid_domain)
347            .as_bytes();
348        let domain = self.d.as_bytes();
349
350        match auid_domain
351            .len()
352            .checked_sub(domain.len())
353            .and_then(|split| auid_domain.split_at_checked(split))
354        {
355            Some((parent, suffix)) if suffix.eq_ignore_ascii_case(domain) => {
356                parent.is_empty() || (!record.has_flag(Flag::MatchDomain) && parent.ends_with(b"."))
357            }
358            _ => false,
359        }
360    }
361}
362
363pub(crate) trait Verifier: Sized {
364    fn strip_signature(&self) -> Vec<u8>;
365}
366
367#[derive(Clone, Copy, PartialEq, Eq)]
368enum TagState {
369    Semicolon,
370    Tag,
371    Value,
372    Signature,
373}
374
375fn strip_tag_segment(
376    segment: &[u8],
377    terminated: bool,
378    state: TagState,
379    unsigned_dkim: &mut Vec<u8>,
380) -> TagState {
381    if state != TagState::Semicolon {
382        unsigned_dkim.extend_from_slice(segment);
383        return if terminated {
384            TagState::Semicolon
385        } else {
386            state
387        };
388    }
389
390    let tag = segment
391        .iter()
392        .position(|ch| !ch.is_ascii_whitespace())
393        .unwrap_or(segment.len());
394
395    if !matches!(segment.get(tag), Some(b'b' | b'B')) {
396        unsigned_dkim.extend_from_slice(segment);
397        return if terminated || tag == segment.len() {
398            TagState::Semicolon
399        } else {
400            TagState::Value
401        };
402    }
403
404    let after_tag = &segment[tag + 1..];
405    let equals = after_tag
406        .iter()
407        .position(|ch| !ch.is_ascii_whitespace())
408        .unwrap_or(after_tag.len());
409
410    if after_tag.get(equals) != Some(&b'=') {
411        unsigned_dkim.extend_from_slice(segment);
412        return if terminated {
413            TagState::Semicolon
414        } else if equals == after_tag.len() {
415            TagState::Tag
416        } else {
417            TagState::Value
418        };
419    }
420
421    unsigned_dkim.extend_from_slice(&segment[..tag + equals + 2]);
422    if terminated {
423        unsigned_dkim.push(b';');
424        TagState::Value
425    } else {
426        TagState::Signature
427    }
428}
429
430fn strip_tag_list(mut rest: &[u8], unsigned_dkim: &mut Vec<u8>) -> TagState {
431    let mut state = TagState::Semicolon;
432    loop {
433        match memchr::memchr(b';', rest) {
434            Some(position) => {
435                let (segment, tail) = rest.split_at(position + 1);
436                state = strip_tag_segment(segment, true, state, unsigned_dkim);
437                rest = tail;
438            }
439            None => return strip_tag_segment(rest, false, state, unsigned_dkim),
440        }
441    }
442}
443
444fn strip_trailing_byte(ch: u8, discard: bool, state: &mut TagState, unsigned_dkim: &mut Vec<u8>) {
445    if *state == TagState::Signature {
446        if ch == b';' {
447            unsigned_dkim.push(b';');
448            *state = TagState::Semicolon;
449        }
450        return;
451    }
452
453    match ch {
454        b'=' if *state == TagState::Tag => {
455            unsigned_dkim.push(ch);
456            *state = TagState::Signature;
457        }
458        b'b' | b'B' if *state == TagState::Semicolon => {
459            unsigned_dkim.push(ch);
460            *state = TagState::Tag;
461        }
462        b';' => {
463            unsigned_dkim.push(ch);
464            *state = TagState::Semicolon;
465        }
466        _ if discard => (),
467        _ => {
468            unsigned_dkim.push(ch);
469            if !ch.is_ascii_whitespace() {
470                *state = TagState::Value;
471            }
472        }
473    }
474}
475
476impl Verifier for &[u8] {
477    fn strip_signature(&self) -> Vec<u8> {
478        let mut unsigned_dkim = Vec::with_capacity(self.len());
479        let (head, tail) = match self.len() {
480            0 => return unsigned_dkim,
481            1 => self.split_at(0),
482            len => self.split_at(len - 2),
483        };
484
485        let mut state = strip_tag_list(head, &mut unsigned_dkim);
486        match tail {
487            [cr, lf] => {
488                strip_trailing_byte(*cr, *cr == b'\r', &mut state, &mut unsigned_dkim);
489                strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim);
490            }
491            [lf] => strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim),
492            _ => (),
493        }
494
495        unsigned_dkim
496    }
497}
498
499impl<'x> From<&'x AuthenticatedMessage<'x>>
500    for Parameters<
501        'x,
502        &'x AuthenticatedMessage<'x>,
503        NoCache<Box<str>, Txt>,
504        NoCache<Box<str>, RecordSet<MX>>,
505        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
506        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
507        NoCache<IpAddr, RecordSet<Box<str>>>,
508    >
509{
510    fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
511        Parameters::new(params)
512    }
513}
514
515#[cfg(test)]
516#[allow(unused)]
517pub mod test {
518    use std::{
519        fs,
520        path::PathBuf,
521        time::{Duration, Instant},
522    };
523
524    use mail_parser::MessageParser;
525
526    use crate::{
527        AuthenticatedMessage, DkimResult, MessageAuthenticator,
528        common::{cache::test::DummyCaches, parse::TxtRecordParser, verify::DomainKey},
529        dkim::{Signature, verify::Verifier},
530    };
531
532    #[test]
533    fn validate_auid() {
534        let strict = DomainKey::parse(
535            b"v=DKIM1; k=ed25519; t=s; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
536        )
537        .unwrap();
538        let relaxed =
539            DomainKey::parse(b"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
540                .unwrap();
541
542        for (auid, strict_expected, relaxed_expected) in [
543            ("", true, true),
544            ("@example.com", true, true),
545            ("john@example.com", true, true),
546            ("@EXAMPLE.com", true, true),
547            ("@sub.example.com", false, true),
548            ("john@deep.sub.example.com", false, true),
549            ("@example.com.evil", false, false),
550            ("@xexample.com", false, false),
551            ("@other.org", false, false),
552            ("john", false, false),
553            ("@", false, false),
554        ] {
555            let signature = Signature {
556                i: auid.to_string(),
557                d: "example.com".to_string(),
558                ..Default::default()
559            };
560            assert_eq!(
561                signature.validate_auid(&strict),
562                strict_expected,
563                "t=s {auid:?}"
564            );
565            assert_eq!(
566                signature.validate_auid(&relaxed),
567                relaxed_expected,
568                "{auid:?}"
569            );
570        }
571    }
572
573    #[tokio::test]
574    async fn dkim_verify() {
575        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
576        test_dir.push("resources");
577        test_dir.push("dkim");
578        let resolver = MessageAuthenticator::new_system_conf().unwrap();
579
580        for file_name in fs::read_dir(&test_dir).unwrap() {
581            let file_name = file_name.unwrap().path();
582            /*if !file_name.to_str().unwrap().contains("002") {
583                continue;
584            }*/
585            println!("DKIM verifying {}", file_name.to_str().unwrap());
586
587            let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
588            let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
589            let caches = new_cache(dns_records);
590            let raw_message = raw_message.replace('\n', "\r\n");
591            let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
592            assert_eq!(
593                message,
594                AuthenticatedMessage::from_parsed(
595                    &MessageParser::new().parse(&raw_message).unwrap(),
596                    raw_message.as_bytes(),
597                    true
598                )
599            );
600
601            let dkim = resolver
602                .verify_dkim_(caches.parameters(&message), 1667843664)
603                .await;
604
605            assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
606        }
607    }
608
609    #[test]
610    fn dkim_strip_signature() {
611        for (value, stripped_value) in [
612            ("b=abc;h=From\r\n", "b=;h=From"),
613            ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
614            ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
615            ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
616        ] {
617            assert_eq!(
618                String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
619                stripped_value
620            );
621        }
622    }
623
624    pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
625        let caches = DummyCaches::new();
626        for (key, value) in dns_records
627            .split('\n')
628            .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
629        {
630            caches.txt_add(
631                format!("{key}."),
632                DomainKey::parse(value).unwrap(),
633                Instant::now() + Duration::new(3200, 0),
634            );
635        }
636
637        caches
638    }
639}