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::new();
302        headers
303            .iter()
304            .filter_map(move |h| {
305                let header_pos = if let Some((_, header_pos)) = last_header_pos
306                    .iter_mut()
307                    .find(|(lh, _)| lh.eq_ignore_ascii_case(h.as_bytes()))
308                {
309                    header_pos
310                } else {
311                    last_header_pos.push((h.as_bytes(), 0));
312                    &mut last_header_pos.last_mut().unwrap().1
313                };
314                if let Some((last_pos, result)) = self
315                    .headers
316                    .iter()
317                    .rev()
318                    .enumerate()
319                    .skip(*header_pos)
320                    .find(|(_, (mh, _))| h.as_bytes().eq_ignore_ascii_case(mh))
321                {
322                    *header_pos = last_pos + 1;
323                    Some(*result)
324                } else {
325                    *header_pos = self.headers.len();
326                    None
327                }
328            })
329            .chain([(dkim_hdr_name, dkim_hdr_value)])
330    }
331}
332
333impl Signature {
334    #[allow(clippy::while_let_on_iterator)]
335    pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
336        // Enforce t=s flag
337        if !self.i.is_empty() && record.has_flag(Flag::MatchDomain) {
338            let mut auid = self.i.chars();
339            let mut domain = self.d.chars();
340            while let Some(ch) = auid.next() {
341                if ch == '@' {
342                    break;
343                }
344            }
345            while let Some(ch) = auid.next() {
346                if let Some(dch) = domain.next() {
347                    if ch != dch {
348                        return false;
349                    }
350                } else {
351                    break;
352                }
353            }
354            if domain.next().is_some() {
355                return false;
356            }
357        }
358
359        true
360    }
361}
362
363pub(crate) trait Verifier: Sized {
364    fn strip_signature(&self) -> Vec<u8>;
365}
366
367impl Verifier for &[u8] {
368    fn strip_signature(&self) -> Vec<u8> {
369        let mut unsigned_dkim = Vec::with_capacity(self.len());
370        let mut iter = self.iter().enumerate();
371        let mut last_ch = b';';
372        while let Some((pos, &ch)) = iter.next() {
373            match ch {
374                b'=' if last_ch == b'b' => {
375                    unsigned_dkim.push(ch);
376                    #[allow(clippy::while_let_on_iterator)]
377                    while let Some((_, &ch)) = iter.next() {
378                        if ch == b';' {
379                            unsigned_dkim.push(b';');
380                            break;
381                        }
382                    }
383                    last_ch = 0;
384                }
385                b'b' | b'B' if last_ch == b';' => {
386                    last_ch = b'b';
387                    unsigned_dkim.push(ch);
388                }
389                b';' => {
390                    last_ch = b';';
391                    unsigned_dkim.push(ch);
392                }
393                b'\r' if pos == self.len() - 2 => (),
394                b'\n' if pos == self.len() - 1 => (),
395                _ => {
396                    unsigned_dkim.push(ch);
397                    if !ch.is_ascii_whitespace() {
398                        last_ch = 0;
399                    }
400                }
401            }
402        }
403        unsigned_dkim
404    }
405}
406
407impl<'x> From<&'x AuthenticatedMessage<'x>>
408    for Parameters<
409        'x,
410        &'x AuthenticatedMessage<'x>,
411        NoCache<Box<str>, Txt>,
412        NoCache<Box<str>, RecordSet<MX>>,
413        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
414        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
415        NoCache<IpAddr, RecordSet<Box<str>>>,
416    >
417{
418    fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
419        Parameters::new(params)
420    }
421}
422
423#[cfg(test)]
424#[allow(unused)]
425pub mod test {
426    use std::{
427        fs,
428        path::PathBuf,
429        time::{Duration, Instant},
430    };
431
432    use mail_parser::MessageParser;
433
434    use crate::{
435        AuthenticatedMessage, DkimResult, MessageAuthenticator,
436        common::{cache::test::DummyCaches, parse::TxtRecordParser, verify::DomainKey},
437        dkim::verify::Verifier,
438    };
439
440    #[tokio::test]
441    async fn dkim_verify() {
442        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
443        test_dir.push("resources");
444        test_dir.push("dkim");
445        let resolver = MessageAuthenticator::new_system_conf().unwrap();
446
447        for file_name in fs::read_dir(&test_dir).unwrap() {
448            let file_name = file_name.unwrap().path();
449            /*if !file_name.to_str().unwrap().contains("002") {
450                continue;
451            }*/
452            println!("DKIM verifying {}", file_name.to_str().unwrap());
453
454            let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
455            let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
456            let caches = new_cache(dns_records);
457            let raw_message = raw_message.replace('\n', "\r\n");
458            let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
459            assert_eq!(
460                message,
461                AuthenticatedMessage::from_parsed(
462                    &MessageParser::new().parse(&raw_message).unwrap(),
463                    raw_message.as_bytes(),
464                    true
465                )
466            );
467
468            let dkim = resolver
469                .verify_dkim_(caches.parameters(&message), 1667843664)
470                .await;
471
472            assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
473        }
474    }
475
476    #[test]
477    fn dkim_strip_signature() {
478        for (value, stripped_value) in [
479            ("b=abc;h=From\r\n", "b=;h=From"),
480            ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
481            ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
482            ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
483        ] {
484            assert_eq!(
485                String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
486                stripped_value
487            );
488        }
489    }
490
491    pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
492        let caches = DummyCaches::new();
493        for (key, value) in dns_records
494            .split('\n')
495            .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
496        {
497            caches.txt_add(
498                format!("{key}."),
499                DomainKey::parse(value).unwrap(),
500                Instant::now() + Duration::new(3200, 0),
501            );
502        }
503
504        caches
505    }
506}