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