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