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