kumo_dkim/
hash.rs

1use crate::{canonicalization, DKIMError, ParsedEmail, TaggedHeader};
2use data_encoding::BASE64;
3use mailparsing::Header;
4use sha1::{Digest as _, Sha1};
5use sha2::Sha256;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy)]
9pub enum HashAlgo {
10    RsaSha1,
11    RsaSha256,
12    Ed25519Sha256,
13}
14
15impl HashAlgo {
16    pub fn algo_name(&self) -> &'static str {
17        match self {
18            Self::RsaSha1 => "rsa-sha1",
19            Self::RsaSha256 => "rsa-sha256",
20            Self::Ed25519Sha256 => "ed25519-sha256",
21        }
22    }
23}
24
25pub(crate) struct LimitHasher {
26    pub limit: usize,
27    pub hashed: usize,
28    pub hasher: HashImpl,
29}
30
31impl LimitHasher {
32    pub fn hash(&mut self, bytes: &[u8]) {
33        let remain = self.limit - self.hashed;
34        let len = bytes.len().min(remain);
35        self.hasher.hash(&bytes[..len]);
36        self.hashed += len;
37    }
38
39    pub fn finalize(self) -> String {
40        self.hasher.finalize()
41    }
42
43    #[cfg(test)]
44    pub fn finalize_bytes(self) -> Vec<u8> {
45        self.hasher.finalize_bytes()
46    }
47}
48
49pub(crate) enum HashImpl {
50    Sha1(Sha1),
51    Sha256(Sha256),
52    #[cfg(test)]
53    Copy(Vec<u8>),
54}
55
56impl HashImpl {
57    pub fn from_algo(algo: HashAlgo) -> Self {
58        match algo {
59            HashAlgo::RsaSha1 => Self::Sha1(Sha1::new()),
60            HashAlgo::RsaSha256 | HashAlgo::Ed25519Sha256 => Self::Sha256(Sha256::new()),
61        }
62    }
63
64    #[cfg(test)]
65    pub fn copy_data() -> Self {
66        Self::Copy(vec![])
67    }
68
69    pub fn hash(&mut self, bytes: &[u8]) {
70        match self {
71            Self::Sha1(hasher) => hasher.update(bytes),
72            Self::Sha256(hasher) => hasher.update(bytes),
73            #[cfg(test)]
74            Self::Copy(data) => data.extend_from_slice(bytes),
75        }
76    }
77
78    pub fn finalize(self) -> String {
79        match self {
80            Self::Sha1(hasher) => BASE64.encode(&hasher.finalize()),
81            Self::Sha256(hasher) => BASE64.encode(&hasher.finalize()),
82            #[cfg(test)]
83            Self::Copy(data) => String::from_utf8_lossy(&data).into(),
84        }
85    }
86
87    pub fn finalize_bytes(self) -> Vec<u8> {
88        match self {
89            Self::Sha1(hasher) => hasher.finalize().to_vec(),
90            Self::Sha256(hasher) => hasher.finalize().to_vec(),
91            #[cfg(test)]
92            Self::Copy(data) => data,
93        }
94    }
95}
96
97/// Returns the hash of message's body
98/// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
99pub(crate) fn compute_body_hash<'a>(
100    canonicalization_type: canonicalization::Type,
101    length: Option<usize>,
102    hash_algo: HashAlgo,
103    email: &'a ParsedEmail<'a>,
104) -> Result<String, DKIMError> {
105    let body = email.get_body();
106    let limit = length.unwrap_or(usize::MAX);
107
108    let mut hasher = LimitHasher {
109        hasher: HashImpl::from_algo(hash_algo),
110        limit,
111        hashed: 0,
112    };
113
114    canonicalization_type.canon_body(body.as_bytes(), &mut hasher);
115
116    Ok(hasher.finalize())
117}
118
119/// Holds a list of header names, normalized to lower case
120#[derive(Debug)]
121pub(crate) struct HeaderList(Vec<String>);
122
123impl HeaderList {
124    pub fn as_h_list(&self) -> String {
125        self.0.join(":")
126    }
127
128    /// Computes the header list that should be used to over-sign
129    /// the provided email message, and returns it
130    pub fn compute_over_signed(&self, email: &ParsedEmail) -> Self {
131        let unique_header_names = {
132            // Note that Self::new() normalized the names, so we can
133            // simply dedup without further concern for normalization
134            let mut n = self.0.clone();
135            n.sort();
136            n.dedup();
137            n
138        };
139
140        let email_headers = email.get_headers();
141
142        let mut result = vec![];
143        for name in unique_header_names {
144            for _ in email_headers.iter_named(&name) {
145                result.push(name.clone());
146            }
147            result.push(name);
148        }
149
150        Self(result)
151    }
152
153    /// Build a header list.
154    /// Analyzes the list to determine whether it is a unique list or not
155    pub fn new(list: Vec<String>) -> Self {
156        let normalized: Vec<String> = list.into_iter().map(|s| s.to_ascii_lowercase()).collect();
157        Self(normalized)
158    }
159
160    /// matches the headers, returning them in the order set out in Section 5.4.2
161    /// Signers wishing to sign multiple instances of such a header field MUST
162    /// include the header field name multiple times in the "h=" tag of the
163    /// DKIM-Signature header field and MUST sign such header fields in order
164    /// from the bottom of the header field block to the top.
165    ///
166    /// To facilitate this, we need to maintain state for each header name
167    /// in the list to ensure that we select the appropriate header in the
168    /// appropriate order.
169    pub fn compute_concrete_header_list<'a>(&self, email: &'a ParsedEmail) -> Vec<&'a Header<'a>> {
170        let mut headers = vec![];
171        let email_headers = email.get_headers();
172        let num_headers = email_headers.len();
173
174        // Note: this map only works correctly if the header names are normalized
175        // to lower case.  That happens in our constructor.
176        let mut last_index: HashMap<&String, usize> = HashMap::new();
177
178        'outer: for name in &self.0 {
179            let index = last_index.get(name).unwrap_or(&num_headers);
180            for (header_index, header) in email_headers
181                .iter()
182                .enumerate()
183                .rev()
184                .skip(num_headers - index)
185            {
186                if header.get_name().eq_ignore_ascii_case(name) {
187                    headers.push(header);
188                    last_index.insert(name, header_index);
189                    continue 'outer;
190                }
191            }
192
193            // When computing the signature, the nonexisting header field MUST be
194            // treated as the null string (including the header field name, header
195            // field value, all punctuation, and the trailing CRLF).
196            // -> don't include it in the returned signed_headers.
197            // FIXME: something is fishy here.
198
199            last_index.insert(name, 0);
200        }
201        headers
202    }
203}
204
205pub(crate) fn compute_headers_hash<'a>(
206    canonicalization_type: canonicalization::Type,
207    header_list: &Vec<&Header>,
208    hash_algo: HashAlgo,
209    dkim_header: &TaggedHeader,
210    signature_header_name: &str,
211) -> Result<Vec<u8>, DKIMError> {
212    let mut input = Vec::new();
213    let mut hasher = HashImpl::from_algo(hash_algo);
214
215    for header in header_list {
216        canonicalization_type.canon_header_into(
217            header.get_name(),
218            header.get_raw_value().as_bytes(),
219            &mut input,
220        );
221    }
222
223    // Add the DKIM-Signature header in the hash. Remove the value of the
224    // signature (b) first.
225    {
226        let sign = dkim_header.get_required_raw_tag("b");
227        let value = dkim_header.raw().replace(sign, "");
228        let mut canonicalized_value = vec![];
229        canonicalization_type.canon_header_into(
230            signature_header_name,
231            value.as_bytes(),
232            &mut canonicalized_value,
233        );
234
235        // remove trailing "\r\n"
236        canonicalized_value.truncate(canonicalized_value.len() - 2);
237
238        input.extend_from_slice(&canonicalized_value);
239    }
240    tracing::debug!("headers to hash: {:?}", input);
241
242    hasher.hash(&input);
243    let hash = hasher.finalize_bytes();
244    Ok(hash)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::{DKIMHeader, DKIM_SIGNATURE_HEADER_NAME};
251
252    fn dkim_header() -> DKIMHeader {
253        DKIMHeader::parse("v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; s=smtp; d=test.com; t=1641506955; h=content-type:to: subject:date:from:mime-version:sender; bh=PU2XIErWsXvhvt1W96ntPWZ2VImjVZ3vBY2T/A+wA3A=; b=PIO0A014nyntOGKdTdtvCJor9ZxvP1M3hoLeEh8HqZ+RvAyEKdAc7VOg+/g/OTaZgsmw6U sZCoN0YNVp+2o9nkaeUslsVz3M4I55HcZnarxl+fhplIMcJ/3s0nIhXL51MfGPRqPbB7/M Gjg9/07/2vFoid6Kitg6Z+CfoD2wlSRa8xDfmeyA2cHpeVuGQhGxu7BXuU8kGbeM4+weit Ql3t9zalhikEPI5Pr7dzYFrgWNOEO6w6rQfG7niKON1BimjdbJlGanC7cO4UL361hhXT4X iXLnC9TG39xKFPT/+4nkHy8pp6YvWkD3wKlBjwkYNm0JvKGwTskCMDeTwxXhAg==").unwrap()
254    }
255
256    #[test]
257    fn test_compute_body_hash_simple() {
258        let email = r#"To: test@sauleau.com
259Subject: subject
260From: Sven Sauleau <sven@cloudflare.com>
261
262Hello Alice
263        "#
264        .replace("\n", "\r\n");
265        let email = ParsedEmail::parse(email).unwrap();
266
267        let canonicalization_type = canonicalization::Type::Simple;
268        let length = None;
269        let hash_algo = HashAlgo::RsaSha1;
270        assert_eq!(
271            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
272            "ya82MJvChLGBNSxeRvrSat5LliQ="
273        );
274        let hash_algo = HashAlgo::RsaSha256;
275        assert_eq!(
276            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
277            "KXQwQpX2zFwgixPbV6Dd18ZMJU04lLeRnwqzUp8uGwI=",
278        )
279    }
280
281    #[test]
282    fn test_compute_body_hash_relaxed() {
283        let email = r#"To: test@sauleau.com
284Subject: subject
285From: Sven Sauleau <sven@cloudflare.com>
286
287Hello Alice
288        "#
289        .replace("\n", "\r\n");
290        let email = ParsedEmail::parse(email).unwrap();
291
292        let canonicalization_type = canonicalization::Type::Relaxed;
293        let length = None;
294        let hash_algo = HashAlgo::RsaSha1;
295        assert_eq!(
296            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
297            "wpj48VhihzV7I31ZZZUp1UpTyyM="
298        );
299        let hash_algo = HashAlgo::RsaSha256;
300        assert_eq!(
301            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
302            "1bokzbYiRgXTKMQhrNhLJo1kjDDA1GILbpyTwyNa1uk=",
303        )
304    }
305
306    #[test]
307    fn test_compute_body_hash_length() {
308        let email = r#"To: test@sauleau.com
309Subject: subject
310From: Sven Sauleau <sven@cloudflare.com>
311
312Hello Alice
313        "#
314        .replace("\n", "\r\n");
315        let email = ParsedEmail::parse(email).unwrap();
316
317        let canonicalization_type = canonicalization::Type::Relaxed;
318        let length = Some(3);
319        let hash_algo = HashAlgo::RsaSha1;
320        assert_eq!(
321            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
322            "28LR/tDcN6cK6g83aVjIAu3cBVk="
323        );
324        let hash_algo = HashAlgo::RsaSha256;
325        assert_eq!(
326            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
327            "t4nCTc22jEQ3sEwYa/I5pyB+dXP7GyKnSf4ae42W0pI=",
328        )
329    }
330
331    #[test]
332    fn test_compute_body_hash_empty_simple() {
333        let email = ParsedEmail::parse("Subject: nothing\r\n\r\n").unwrap();
334
335        let canonicalization_type = canonicalization::Type::Simple;
336        let length = None;
337        let hash_algo = HashAlgo::RsaSha1;
338        assert_eq!(
339            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
340            "uoq1oCgLlTqpdDX/iUbLy7J1Wic="
341        );
342        let hash_algo = HashAlgo::RsaSha256;
343        assert_eq!(
344            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
345            "frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY="
346        )
347    }
348
349    #[test]
350    fn test_compute_body_hash_empty_relaxed() {
351        let email = ParsedEmail::parse("Subject: nothing\r\n\r\n").unwrap();
352
353        let canonicalization_type = canonicalization::Type::Relaxed;
354        let length = None;
355        let hash_algo = HashAlgo::RsaSha1;
356        assert_eq!(
357            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
358            "2jmj7l5rSw0yVb/vlWAYkK/YBwk="
359        );
360        let hash_algo = HashAlgo::RsaSha256;
361        assert_eq!(
362            compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
363            "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
364        )
365    }
366
367    #[test]
368    fn test_compute_headers_hash_simple() {
369        let email = r#"To: test@sauleau.com
370Subject: subject
371From: Sven Sauleau <sven@cloudflare.com>
372
373Hello Alice
374        "#
375        .replace("\n", "\r\n");
376        let email = ParsedEmail::parse(email).unwrap();
377
378        let canonicalization_type = canonicalization::Type::Simple;
379        let hash_algo = HashAlgo::RsaSha1;
380        let headers = HeaderList::new(vec!["To".to_owned(), "Subject".to_owned()])
381            .compute_concrete_header_list(&email);
382        assert_eq!(
383            compute_headers_hash(
384                canonicalization_type,
385                &headers,
386                hash_algo,
387                &dkim_header(),
388                DKIM_SIGNATURE_HEADER_NAME
389            )
390            .unwrap(),
391            &[
392                214, 155, 167, 0, 209, 70, 127, 126, 160, 53, 79, 106, 141, 240, 35, 121, 255, 190,
393                166, 229
394            ],
395        );
396        let hash_algo = HashAlgo::RsaSha256;
397        assert_eq!(
398            compute_headers_hash(
399                canonicalization_type,
400                &headers,
401                hash_algo,
402                &dkim_header(),
403                DKIM_SIGNATURE_HEADER_NAME
404            )
405            .unwrap(),
406            &[
407                76, 143, 13, 248, 17, 209, 243, 111, 40, 96, 160, 242, 116, 86, 37, 249, 134, 253,
408                196, 89, 6, 24, 157, 130, 142, 198, 27, 166, 127, 179, 72, 247
409            ]
410        )
411    }
412
413    #[test]
414    fn test_compute_headers_hash_relaxed() {
415        let email = r#"To: test@sauleau.com
416Subject: subject
417From: Sven Sauleau <sven@cloudflare.com>
418
419Hello Alice
420        "#
421        .replace("\n", "\r\n");
422        let email = ParsedEmail::parse(email).unwrap();
423
424        let canonicalization_type = canonicalization::Type::Relaxed;
425        let hash_algo = HashAlgo::RsaSha1;
426        let headers = HeaderList::new(vec!["To".to_owned(), "Subject".to_owned()])
427            .compute_concrete_header_list(&email);
428        assert_eq!(
429            compute_headers_hash(
430                canonicalization_type,
431                &headers,
432                hash_algo,
433                &dkim_header(),
434                DKIM_SIGNATURE_HEADER_NAME
435            )
436            .unwrap(),
437            &[
438                14, 171, 230, 1, 77, 117, 47, 207, 243, 167, 179, 5, 150, 82, 154, 25, 125, 124,
439                44, 164
440            ]
441        );
442        let hash_algo = HashAlgo::RsaSha256;
443        assert_eq!(
444            compute_headers_hash(
445                canonicalization_type,
446                &headers,
447                hash_algo,
448                &dkim_header(),
449                DKIM_SIGNATURE_HEADER_NAME
450            )
451            .unwrap(),
452            &[
453                45, 186, 211, 81, 49, 111, 18, 147, 180, 245, 207, 39, 9, 9, 118, 137, 248, 204,
454                70, 214, 16, 98, 216, 111, 230, 130, 196, 3, 60, 201, 166, 224
455            ]
456        )
457    }
458
459    #[test]
460    fn test_get_body() {
461        let email = ParsedEmail::parse("Subject: A\r\n\r\nContent\n.hi\n.hello..").unwrap();
462        assert_eq!(email.get_body(), "Content\n.hi\n.hello..");
463    }
464
465    fn select_headers<'a>(
466        header_list: &HeaderList,
467        email: &'a ParsedEmail,
468    ) -> Vec<(&'a str, &'a [u8])> {
469        header_list
470            .compute_concrete_header_list(email)
471            .into_iter()
472            .map(|header| (header.get_name(), header.get_raw_value().as_bytes()))
473            .collect()
474    }
475
476    #[test]
477    fn test_select_headers_unique() {
478        let header_list = HeaderList::new(vec![
479            "from".to_string(),
480            "subject".to_string(),
481            "to".to_string(),
482        ]);
483
484        let email1 =
485            ParsedEmail::parse("from: biz\r\nfoo: bar\r\nfrom: baz\r\nsubject: boring\r\n\r\ntest")
486                .unwrap();
487
488        let result1 = select_headers(&header_list, &email1);
489        assert_eq!(
490            result1,
491            vec![("from", &b"baz"[..]), ("subject", &b"boring"[..]),]
492        );
493
494        let email2 =
495            ParsedEmail::parse("From: biz\r\nFoo: bar\r\nSubject: Boring\r\n\r\ntest").unwrap();
496
497        let result2 = select_headers(&header_list, &email2);
498        assert_eq!(
499            result2,
500            vec![("From", &b"biz"[..]), ("Subject", &b"Boring"[..]),]
501        );
502    }
503
504    #[test]
505    fn test_select_headers_multiple() {
506        let header_list = HeaderList::new(vec![
507            "from".to_string(),
508            "subject".to_string(),
509            "to".to_string(),
510            "from".to_string(),
511        ]);
512
513        let email1 =
514            ParsedEmail::parse("from: biz\r\nfoo: bar\r\nfrom: baz\r\nsubject: boring\r\n\r\ntest")
515                .unwrap();
516
517        let result1 = select_headers(&header_list, &email1);
518        assert_eq!(
519            result1,
520            vec![
521                ("from", &b"baz"[..]),
522                ("subject", &b"boring"[..]),
523                ("from", &b"biz"[..]),
524            ]
525        );
526
527        let email2 =
528            ParsedEmail::parse("From: biz\r\nFoo: bar\r\nSubject: Boring\r\n\r\ntest").unwrap();
529
530        let result2 = select_headers(&header_list, &email2);
531        assert_eq!(
532            result2,
533            vec![("From", &b"biz"[..]), ("Subject", &b"Boring"[..]),]
534        );
535    }
536}