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::{
8    AuthenticatedMessage, DkimOutput, DkimResult, Error, MX, MessageAuthenticator, Parameters,
9    ResolverCache, Txt,
10    common::{
11        base32::Base32Writer,
12        cache::NoCache,
13        headers::Writer,
14        verify::{DomainKey, VerifySignature},
15    },
16    is_within_pct,
17};
18use std::{
19    net::{IpAddr, Ipv4Addr, Ipv6Addr},
20    sync::Arc,
21    time::SystemTime,
22};
23
24use super::{
25    Atps, DomainKeyReport, Flag, HashAlgorithm, RR_DNS, RR_EXPIRATION, RR_OTHER, RR_SIGNATURE,
26    RR_VERIFICATION, Signature,
27};
28
29impl MessageAuthenticator {
30    /// Verifies DKIM headers of an RFC5322 message.
31    #[inline(always)]
32    pub async fn verify_dkim<'x, TXT, MXX, IPV4, IPV6, PTR>(
33        &self,
34        params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
35    ) -> Vec<DkimOutput<'x>>
36    where
37        TXT: ResolverCache<String, Txt> + 'x,
38        MXX: ResolverCache<String, Arc<Vec<MX>>> + 'x,
39        IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>> + 'x,
40        IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>> + 'x,
41        PTR: ResolverCache<IpAddr, Arc<Vec<String>>> + 'x,
42    {
43        self.verify_dkim_(
44            params.into(),
45            SystemTime::now()
46                .duration_since(SystemTime::UNIX_EPOCH)
47                .map_or(0, |d| d.as_secs()),
48        )
49        .await
50    }
51
52    pub(crate) async fn verify_dkim_<'x, TXT, MXX, IPV4, IPV6, PTR>(
53        &self,
54        params: Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>,
55        now: u64,
56    ) -> Vec<DkimOutput<'x>>
57    where
58        TXT: ResolverCache<String, Txt>,
59        MXX: ResolverCache<String, Arc<Vec<MX>>>,
60        IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>>,
61        IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>>,
62        PTR: ResolverCache<IpAddr, Arc<Vec<String>>>,
63    {
64        let message = params.params;
65        let mut output = Vec::with_capacity(message.dkim_headers.len());
66        let mut report_requested = false;
67
68        // Validate DKIM headers
69        for header in &message.dkim_headers {
70            // Validate body hash
71            let signature = match &header.header {
72                Ok(signature) => {
73                    if signature.r {
74                        report_requested = true;
75                    }
76
77                    if signature.x == 0 || (signature.x > signature.t && signature.x > now) {
78                        signature
79                    } else {
80                        output.push(
81                            DkimOutput::neutral(Error::SignatureExpired).with_signature(signature),
82                        );
83                        continue;
84                    }
85                }
86                Err(err) => {
87                    output.push(DkimOutput::neutral(err.clone()));
88                    continue;
89                }
90            };
91
92            // Validate body hash
93            let ha = HashAlgorithm::from(signature.a);
94            let bh = &message
95                .body_hashes
96                .iter()
97                .find(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
98                .unwrap()
99                .3;
100
101            if bh != &signature.bh {
102                output.push(
103                    DkimOutput::neutral(Error::FailedBodyHashMatch).with_signature(signature),
104                );
105                continue;
106            }
107
108            // Obtain ._domainkey TXT record
109            let record = match self
110                .txt_lookup::<DomainKey>(signature.domain_key(), params.cache_txt)
111                .await
112            {
113                Ok(record) => record,
114                Err(err) => {
115                    output.push(DkimOutput::dns_error(err).with_signature(signature));
116                    continue;
117                }
118            };
119
120            // Enforce t=s flag
121            if !signature.validate_auid(&record) {
122                output.push(DkimOutput::fail(Error::FailedAuidMatch).with_signature(signature));
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::CryptoError(_)
226                            | Error::Io(_)
227                            | Error::FailedVerification
228                            | Error::FailedBodyHashMatch
229                            | Error::FailedAuidMatch => (record.rr & RR_VERIFICATION) != 0,
230                            Error::Base64
231                            | Error::UnsupportedVersion
232                            | Error::UnsupportedAlgorithm
233                            | Error::UnsupportedCanonicalization
234                            | Error::UnsupportedKeyType
235                            | Error::IncompatibleAlgorithms => (record.rr & RR_SIGNATURE) != 0,
236                            Error::SignatureExpired => (record.rr & RR_EXPIRATION) != 0,
237                            Error::DnsError(_)
238                            | Error::DnsRecordNotFound(_)
239                            | Error::InvalidRecordType
240                            | Error::ParseError
241                            | Error::RevokedPublicKey => (record.rr & RR_DNS) != 0,
242                            Error::MissingParameters
243                            | Error::NoHeadersFound
244                            | Error::ArcChainTooLong
245                            | Error::ArcInvalidInstance(_)
246                            | Error::ArcInvalidCV
247                            | Error::ArcHasHeaderTag
248                            | Error::ArcBrokenChain
249                            | Error::SignatureLength
250                            | Error::NotAligned => (record.rr & RR_OTHER) != 0,
251                        };
252
253                        if send_report {
254                            format!("{}@{}", record.ra, signature.d).into()
255                        } else {
256                            None
257                        }
258                    }
259                    DkimResult::None | DkimResult::Pass => None,
260                };
261            }
262        }
263
264        output
265    }
266}
267
268impl<'x> AuthenticatedMessage<'x> {
269    pub async fn get_canonicalized_header(&self) -> Result<Vec<u8>, Error> {
270        // Based on verify_dkim_ function
271        // Iterate through possible DKIM headers
272        let mut data = Vec::with_capacity(256);
273        for header in &self.dkim_headers {
274            // Ensure signature is not obviously invalid
275            let signature = match &header.header {
276                Ok(signature) => {
277                    if signature.x == 0 || (signature.x > signature.t) {
278                        signature
279                    } else {
280                        continue;
281                    }
282                }
283                Err(_err) => {
284                    continue;
285                }
286            };
287
288            // Get pre-hashed but canonically ordered headers, who's hash is signed
289            let dkim_hdr_value = header.value.strip_signature();
290            let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
291            signature.ch.canonicalize_headers(headers, &mut data);
292
293            return Ok(data);
294        }
295        // Return not ok
296        Err(Error::FailedBodyHashMatch)
297    }
298
299    pub fn signed_headers<'z: 'x>(
300        &'z self,
301        headers: &'x [String],
302        dkim_hdr_name: &'x [u8],
303        dkim_hdr_value: &'x [u8],
304    ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
305        let mut last_header_pos: Vec<(&[u8], usize)> = Vec::new();
306        headers
307            .iter()
308            .filter_map(move |h| {
309                let header_pos = if let Some((_, header_pos)) = last_header_pos
310                    .iter_mut()
311                    .find(|(lh, _)| lh.eq_ignore_ascii_case(h.as_bytes()))
312                {
313                    header_pos
314                } else {
315                    last_header_pos.push((h.as_bytes(), 0));
316                    &mut last_header_pos.last_mut().unwrap().1
317                };
318                if let Some((last_pos, result)) = self
319                    .headers
320                    .iter()
321                    .rev()
322                    .enumerate()
323                    .skip(*header_pos)
324                    .find(|(_, (mh, _))| h.as_bytes().eq_ignore_ascii_case(mh))
325                {
326                    *header_pos = last_pos + 1;
327                    Some(*result)
328                } else {
329                    *header_pos = self.headers.len();
330                    None
331                }
332            })
333            .chain([(dkim_hdr_name, dkim_hdr_value)])
334    }
335}
336
337impl Signature {
338    #[allow(clippy::while_let_on_iterator)]
339    pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
340        // Enforce t=s flag
341        if !self.i.is_empty() && record.has_flag(Flag::MatchDomain) {
342            let mut auid = self.i.chars();
343            let mut domain = self.d.chars();
344            while let Some(ch) = auid.next() {
345                if ch == '@' {
346                    break;
347                }
348            }
349            while let Some(ch) = auid.next() {
350                if let Some(dch) = domain.next() {
351                    if ch != dch {
352                        return false;
353                    }
354                } else {
355                    break;
356                }
357            }
358            if domain.next().is_some() {
359                return false;
360            }
361        }
362
363        true
364    }
365}
366
367pub(crate) trait Verifier: Sized {
368    fn strip_signature(&self) -> Vec<u8>;
369}
370
371impl Verifier for &[u8] {
372    fn strip_signature(&self) -> Vec<u8> {
373        let mut unsigned_dkim = Vec::with_capacity(self.len());
374        let mut iter = self.iter().enumerate();
375        let mut last_ch = b';';
376        while let Some((pos, &ch)) = iter.next() {
377            match ch {
378                b'=' if last_ch == b'b' => {
379                    unsigned_dkim.push(ch);
380                    #[allow(clippy::while_let_on_iterator)]
381                    while let Some((_, &ch)) = iter.next() {
382                        if ch == b';' {
383                            unsigned_dkim.push(b';');
384                            break;
385                        }
386                    }
387                    last_ch = 0;
388                }
389                b'b' | b'B' if last_ch == b';' => {
390                    last_ch = b'b';
391                    unsigned_dkim.push(ch);
392                }
393                b';' => {
394                    last_ch = b';';
395                    unsigned_dkim.push(ch);
396                }
397                b'\r' if pos == self.len() - 2 => (),
398                b'\n' if pos == self.len() - 1 => (),
399                _ => {
400                    unsigned_dkim.push(ch);
401                    if !ch.is_ascii_whitespace() {
402                        last_ch = 0;
403                    }
404                }
405            }
406        }
407        unsigned_dkim
408    }
409}
410
411impl<'x> From<&'x AuthenticatedMessage<'x>>
412    for Parameters<
413        'x,
414        &'x AuthenticatedMessage<'x>,
415        NoCache<String, Txt>,
416        NoCache<String, Arc<Vec<MX>>>,
417        NoCache<String, Arc<Vec<Ipv4Addr>>>,
418        NoCache<String, Arc<Vec<Ipv6Addr>>>,
419        NoCache<IpAddr, Arc<Vec<String>>>,
420    >
421{
422    fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
423        Parameters::new(params)
424    }
425}
426
427#[cfg(test)]
428#[allow(unused)]
429pub mod test {
430    use std::{
431        fs,
432        path::PathBuf,
433        time::{Duration, Instant},
434    };
435
436    use mail_parser::MessageParser;
437
438    use crate::{
439        AuthenticatedMessage, DkimResult, MessageAuthenticator,
440        common::{cache::test::DummyCaches, parse::TxtRecordParser, verify::DomainKey},
441        dkim::verify::Verifier,
442    };
443
444    #[tokio::test]
445    async fn dkim_verify() {
446        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
447        test_dir.push("resources");
448        test_dir.push("dkim");
449        let resolver = MessageAuthenticator::new_system_conf().unwrap();
450
451        for file_name in fs::read_dir(&test_dir).unwrap() {
452            let file_name = file_name.unwrap().path();
453            /*if !file_name.to_str().unwrap().contains("002") {
454                continue;
455            }*/
456            println!("DKIM verifying {}", file_name.to_str().unwrap());
457
458            let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
459            let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
460            let caches = new_cache(dns_records);
461            let raw_message = raw_message.replace('\n', "\r\n");
462            let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
463            assert_eq!(
464                message,
465                AuthenticatedMessage::from_parsed(
466                    &MessageParser::new().parse(&raw_message).unwrap(),
467                    true
468                )
469            );
470
471            let dkim = resolver
472                .verify_dkim_(caches.parameters(&message), 1667843664)
473                .await;
474
475            assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
476        }
477    }
478
479    #[test]
480    fn dkim_strip_signature() {
481        for (value, stripped_value) in [
482            ("b=abc;h=From\r\n", "b=;h=From"),
483            ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
484            ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
485            ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
486        ] {
487            assert_eq!(
488                String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
489                stripped_value
490            );
491        }
492    }
493
494    pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
495        let caches = DummyCaches::new();
496        for (key, value) in dns_records
497            .split('\n')
498            .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
499        {
500            caches.txt_add(
501                format!("{key}."),
502                DomainKey::parse(value).unwrap(),
503                Instant::now() + Duration::new(3200, 0),
504            );
505        }
506
507        caches
508    }
509}