Skip to main content

mail_auth/dkim2/
canonicalize.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    common::{
9        crypto::{HashContext, HashImpl, HashOutput, Sha1, Sha256},
10        headers::Writer,
11    },
12    dkim::Canonicalization,
13};
14use std::cmp::Ordering;
15
16impl crate::common::crypto::HashAlgorithm {
17    /// Computes the DKIM2 header-fields hash
18    pub fn headers_hash<'x>(
19        &self,
20        headers: impl IntoIterator<Item = (&'x [u8], &'x [u8])>,
21    ) -> HashOutput {
22        let mut signed: Vec<(&[u8], &[u8])> = headers
23            .into_iter()
24            .filter(|(name, _)| !is_non_signed_header(name))
25            .collect();
26        signed.reverse();
27        signed.sort_by(|(a, _), (b, _)| cmp_ignore_ascii_case(a, b));
28
29        match self {
30            Self::Sha256 => {
31                let mut hasher = Sha256::hasher();
32                Canonicalization::Relaxed.canonicalize_headers(signed.into_iter(), &mut hasher);
33                hasher.complete()
34            }
35            Self::Sha1 => {
36                let mut hasher = Sha1::hasher();
37                Canonicalization::Relaxed.canonicalize_headers(signed.into_iter(), &mut hasher);
38                hasher.complete()
39            }
40        }
41    }
42
43    /// Computes the DKIM2 body hash
44    pub fn body_hash(&self, body: &[u8]) -> HashOutput {
45        self.hash(Canonicalization::Simple.canonical_body(body, u64::MAX))
46    }
47}
48
49pub(crate) struct CanonicalizedHeaderWriter<'x, W: Writer> {
50    inner: &'x mut W,
51}
52
53impl<'x, W: Writer> CanonicalizedHeaderWriter<'x, W> {
54    pub fn new(inner: &'x mut W, field: &[u8]) -> Self {
55        for &ch in field {
56            if !ch.is_ascii_whitespace() {
57                inner.write(&[ch.to_ascii_lowercase()]);
58            }
59        }
60        inner.write(b":");
61
62        Self { inner }
63    }
64
65    pub fn finalize(self) {
66        self.inner.write(b"\r\n");
67    }
68}
69
70impl<'x, W: Writer> Writer for CanonicalizedHeaderWriter<'x, W> {
71    fn write(&mut self, buf: &[u8]) {
72        for &ch in buf {
73            if !ch.is_ascii_whitespace() {
74                self.inner.write(&[ch]);
75            }
76        }
77    }
78}
79
80pub(crate) fn cmp_ignore_ascii_case(a: &[u8], b: &[u8]) -> Ordering {
81    a.iter()
82        .map(u8::to_ascii_lowercase)
83        .cmp(b.iter().map(u8::to_ascii_lowercase))
84}
85
86pub(super) fn is_non_signed_header(name: &[u8]) -> bool {
87    let name = name.trim_ascii();
88    hashify::tiny_map_ignore_case!(name,
89        b"received" => true,
90        b"return-path" => true,
91        b"delivered-to" => true,
92        b"authentication-results" => true,
93        b"dkim-signature" => true,
94        b"message-instance" => true,
95        b"dkim2-signature" => true,
96        b"arc-authentication-results" => true,
97        b"arc-message-signature" => true,
98        b"arc-seal" => true
99    )
100    .unwrap_or_else(|| {
101        matches!(name.get(1), Some(&b'-')) && matches!(name.first(), Some(&b'x' | &b'X'))
102    })
103}
104
105#[cfg(test)]
106mod test {
107    use super::is_non_signed_header;
108    use crate::common::crypto::HashAlgorithm;
109
110    #[test]
111    fn excluded_headers_are_classified() {
112        for name in [
113            "received",
114            "Received",
115            "return-path",
116            "delivered-to",
117            "Delivered-To",
118            "authentication-results",
119            "dkim-signature",
120            "DKIM-Signature",
121            "message-instance",
122            "dkim2-signature",
123            "arc-seal",
124            "arc-message-signature",
125            "arc-authentication-results",
126            "x-spam-score",
127            "X-Anything",
128        ] {
129            assert!(
130                is_non_signed_header(name.as_bytes()),
131                "{name} must be ignored"
132            );
133        }
134        for name in [
135            "from",
136            "to",
137            "subject",
138            "date",
139            "message-id",
140            "list-unsubscribe",
141        ] {
142            assert!(
143                !is_non_signed_header(name.as_bytes()),
144                "{name} must be signed"
145            );
146        }
147    }
148
149    #[test]
150    fn body_hash_ignores_trailing_blank_lines() {
151        let none = HashAlgorithm::Sha256.body_hash(b"Hello, world.");
152        let one = HashAlgorithm::Sha256.body_hash(b"Hello, world.\r\n");
153        let many = HashAlgorithm::Sha256.body_hash(b"Hello, world.\r\n\r\n\r\n");
154        assert_eq!(none, one);
155        assert_eq!(one, many);
156    }
157
158    #[test]
159    fn empty_body_hashes_as_single_crlf() {
160        assert_eq!(
161            HashAlgorithm::Sha256.body_hash(b""),
162            HashAlgorithm::Sha256.body_hash(b"\r\n\r\n")
163        );
164    }
165}