cfdkim/
lib.rs

1// Implementation of DKIM: https://datatracker.ietf.org/doc/html/rfc6376
2
3use base64::engine::general_purpose;
4use base64::Engine;
5use indexmap::map::IndexMap;
6use rsa::Pkcs1v15Sign;
7use rsa::RsaPrivateKey;
8use rsa::RsaPublicKey;
9use sha1::Sha1;
10use sha2::Sha256;
11use slog::debug;
12use std::array::TryFromSliceError;
13use std::collections::HashSet;
14use std::sync::Arc;
15use trust_dns_resolver::TokioAsyncResolver;
16
17use mailparse::MailHeaderMap;
18
19#[macro_use]
20extern crate quick_error;
21
22mod bytes;
23pub mod canonicalization;
24pub mod dns;
25mod errors;
26mod hash;
27mod header;
28mod parser;
29mod public_key;
30mod result;
31#[cfg(test)]
32mod roundtrip_test;
33mod sign;
34
35pub use errors::DKIMError;
36use header::{DKIMHeader, HEADER, REQUIRED_TAGS};
37pub use parser::tag_list as parse_tag_list;
38pub use parser::Tag;
39pub use result::DKIMResult;
40pub use sign::{DKIMSigner, SignerBuilder};
41
42const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
43const DNS_NAMESPACE: &str = "_domainkey";
44
45#[derive(Debug)]
46pub(crate) enum DkimPublicKey {
47    Rsa(RsaPublicKey),
48    Ed25519(ed25519_dalek::VerifyingKey),
49}
50
51#[derive(Debug)]
52pub enum DkimPrivateKey {
53    Rsa(RsaPrivateKey),
54    Ed25519(ed25519_dalek::SigningKey),
55}
56
57// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
58fn validate_header(value: &str) -> Result<DKIMHeader, DKIMError> {
59    let (_, tags) =
60        parser::tag_list(value).map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?;
61
62    // Check presence of required tags
63    {
64        let mut tag_names: HashSet<String> = HashSet::new();
65        for tag in &tags {
66            tag_names.insert(tag.name.clone());
67        }
68        for required in REQUIRED_TAGS {
69            if tag_names.get(*required).is_none() {
70                return Err(DKIMError::SignatureMissingRequiredTag(required));
71            }
72        }
73    }
74
75    let mut tags_map = IndexMap::new();
76    for tag in &tags {
77        tags_map.insert(tag.name.clone(), tag.clone());
78    }
79    let header = DKIMHeader {
80        tags: tags_map,
81        raw_bytes: value.to_owned(),
82    };
83    // FIXME: we could get the keys instead of generating tag_names ourselves
84
85    // Check version
86    {
87        let version = header.get_required_tag("v");
88        if version != "1" {
89            return Err(DKIMError::IncompatibleVersion);
90        }
91    }
92
93    // Check that "d=" tag is the same as or a parent domain of the domain part
94    // of the "i=" tag
95    if let Some(user) = header.get_tag("i") {
96        let signing_domain = header.get_required_tag("d");
97        // TODO: naive check, should switch to parsing the domains/email
98        if !user.ends_with(&signing_domain) {
99            return Err(DKIMError::DomainMismatch);
100        }
101    }
102
103    // Check that "h=" tag includes the From header
104    {
105        let value = header.get_required_tag("h");
106        let headers = value.split(':');
107        let headers: Vec<String> = headers.map(|h| h.to_lowercase()).collect();
108        if !headers.contains(&"from".to_string()) {
109            return Err(DKIMError::FromFieldNotSigned);
110        }
111    }
112
113    if let Some(query_method) = header.get_tag("q") {
114        if query_method != "dns/txt" {
115            return Err(DKIMError::UnsupportedQueryMethod);
116        }
117    }
118
119    // Check that "x=" tag isn't expired
120    if let Some(expiration) = header.get_tag("x") {
121        let mut expiration = chrono::NaiveDateTime::from_timestamp_opt(
122            expiration.parse::<i64>().unwrap_or_default(),
123            0,
124        )
125        .ok_or(DKIMError::SignatureExpired)?;
126        expiration += chrono::Duration::minutes(SIGN_EXPIRATION_DRIFT_MINS);
127        let now = chrono::Utc::now().naive_utc();
128        if now > expiration {
129            return Err(DKIMError::SignatureExpired);
130        }
131    }
132
133    Ok(header)
134}
135
136// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 Step 4
137fn verify_signature(
138    hash_algo: hash::HashAlgo,
139    header_hash: Vec<u8>,
140    signature: Vec<u8>,
141    public_key: DkimPublicKey,
142) -> Result<bool, DKIMError> {
143    Ok(match public_key {
144        DkimPublicKey::Rsa(public_key) => public_key
145            .verify(
146                match hash_algo {
147                    hash::HashAlgo::RsaSha1 => Pkcs1v15Sign::new::<Sha1>(),
148                    hash::HashAlgo::RsaSha256 => Pkcs1v15Sign::new::<Sha256>(),
149                    hash => return Err(DKIMError::UnsupportedHashAlgorithm(format!("{:?}", hash))),
150                },
151                &header_hash,
152                &signature,
153            )
154            .is_ok(),
155        DkimPublicKey::Ed25519(public_key) => public_key
156            .verify_strict(
157                &header_hash,
158                &ed25519_dalek::Signature::from_bytes((&signature as &[u8]).try_into().map_err(
159                    |err: TryFromSliceError| DKIMError::SignatureSyntaxError(err.to_string()),
160                )?),
161            )
162            .is_ok(),
163    })
164}
165
166async fn verify_email_header<'a>(
167    logger: &'a slog::Logger,
168    resolver: Arc<dyn dns::Lookup>,
169    dkim_header: &'a DKIMHeader,
170    email: &'a mailparse::ParsedMail<'a>,
171) -> Result<(canonicalization::Type, canonicalization::Type), DKIMError> {
172    let public_key = public_key::retrieve_public_key(
173        logger,
174        Arc::clone(&resolver),
175        dkim_header.get_required_tag("d"),
176        dkim_header.get_required_tag("s"),
177    )
178    .await?;
179
180    let (header_canonicalization_type, body_canonicalization_type) =
181        parser::parse_canonicalization(dkim_header.get_tag("c"))?;
182    let hash_algo = parser::parse_hash_algo(&dkim_header.get_required_tag("a"))?;
183    let computed_body_hash = hash::compute_body_hash(
184        body_canonicalization_type.clone(),
185        dkim_header.get_tag("l"),
186        hash_algo.clone(),
187        email,
188    )?;
189    let computed_headers_hash = hash::compute_headers_hash(
190        logger,
191        header_canonicalization_type.clone(),
192        &dkim_header.get_required_tag("h"),
193        hash_algo.clone(),
194        dkim_header,
195        email,
196    )?;
197    debug!(logger, "body_hash {:?}", computed_body_hash);
198
199    let header_body_hash = dkim_header.get_required_tag("bh");
200    if header_body_hash != computed_body_hash {
201        return Err(DKIMError::BodyHashDidNotVerify);
202    }
203
204    let signature = general_purpose::STANDARD
205        .decode(dkim_header.get_required_tag("b"))
206        .map_err(|err| {
207            DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
208        })?;
209    if !verify_signature(hash_algo, computed_headers_hash, signature, public_key)? {
210        return Err(DKIMError::SignatureDidNotVerify);
211    }
212
213    Ok((header_canonicalization_type, body_canonicalization_type))
214}
215
216/// Run the DKIM verification on the email providing an existing resolver
217pub async fn verify_email_with_resolver<'a>(
218    logger: &slog::Logger,
219    from_domain: &str,
220    email: &'a mailparse::ParsedMail<'a>,
221    resolver: Arc<dyn dns::Lookup>,
222) -> Result<DKIMResult, DKIMError> {
223    let mut last_error = None;
224
225    for h in email.headers.get_all_headers(HEADER) {
226        let value = String::from_utf8_lossy(h.get_value_raw());
227        debug!(logger, "checking signature {:?}", value);
228
229        let dkim_header = match validate_header(&value) {
230            Ok(v) => v,
231            Err(err) => {
232                debug!(logger, "failed to verify: {}", err);
233                last_error = Some(err);
234                continue;
235            }
236        };
237
238        // Select the signature corresponding to the email sender
239        let signing_domain = dkim_header.get_required_tag("d");
240        if signing_domain.to_lowercase() != from_domain.to_lowercase() {
241            continue;
242        }
243
244        match verify_email_header(logger, Arc::clone(&resolver), &dkim_header, email).await {
245            Ok((header_canonicalization_type, body_canonicalization_type)) => {
246                return Ok(DKIMResult::pass(
247                    signing_domain,
248                    header_canonicalization_type,
249                    body_canonicalization_type,
250                ))
251            }
252            Err(err) => {
253                debug!(logger, "failed to verify: {}", err);
254                last_error = Some(err);
255                continue;
256            }
257        }
258    }
259
260    if let Some(err) = last_error {
261        Ok(DKIMResult::fail(err, from_domain.to_owned()))
262    } else {
263        Ok(DKIMResult::neutral(from_domain.to_owned()))
264    }
265}
266
267/// Run the DKIM verification on the email
268pub async fn verify_email<'a>(
269    logger: &slog::Logger,
270    from_domain: &str,
271    email: &'a mailparse::ParsedMail<'a>,
272) -> Result<DKIMResult, DKIMError> {
273    let resolver = TokioAsyncResolver::tokio_from_system_conf().map_err(|err| {
274        DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
275    })?;
276    let resolver = dns::from_tokio_resolver(resolver);
277
278    verify_email_with_resolver(logger, from_domain, email, resolver).await
279}
280
281#[cfg(test)]
282mod tests {
283    use crate::dns::Lookup;
284
285    use super::*;
286
287    struct MockResolver {}
288
289    impl Lookup for MockResolver {
290        fn lookup_txt<'a>(
291            &'a self,
292            name: &'a str,
293        ) -> futures::future::BoxFuture<'a, Result<Vec<String>, DKIMError>> {
294            match name {
295                "brisbane._domainkey.football.example.com" => {
296                    Box::pin(futures::future::ready(Ok(vec![
297                        "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
298                            .to_string(),
299                    ])))
300                }
301                "newengland._domainkey.example.com" => Box::pin(futures::future::ready(Ok(vec![
302                    "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE=".to_string(),
303                ]))),
304                _ => {
305                    println!("asked to resolve: {}", name);
306                    todo!()
307                }
308            }
309        }
310    }
311
312    impl MockResolver {
313        fn new() -> Self {
314            MockResolver {}
315        }
316    }
317
318    #[test]
319    fn test_validate_header() {
320        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
321c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
322t=1117574938; x=9118006938; l=200;
323h=from:to:subject:date:keywords:keywords;
324z=From:foo@eng.example.net|To:joe@example.com|
325Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
326bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
327b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
328      VoG4ZHRNiYzR
329        "#;
330        validate_header(header).unwrap();
331    }
332
333    #[test]
334    fn test_validate_header_missing_tag() {
335        let header = "v=1; a=rsa-sha256; bh=a; b=b";
336        assert_eq!(
337            validate_header(header).unwrap_err(),
338            DKIMError::SignatureMissingRequiredTag("d")
339        );
340    }
341
342    #[test]
343    fn test_validate_header_domain_mismatch() {
344        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
345        "#;
346        assert_eq!(
347            validate_header(header).unwrap_err(),
348            DKIMError::DomainMismatch
349        );
350    }
351
352    #[test]
353    fn test_validate_header_incompatible_version() {
354        let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
355        "#;
356        assert_eq!(
357            validate_header(header).unwrap_err(),
358            DKIMError::IncompatibleVersion
359        );
360    }
361
362    #[test]
363    fn test_validate_header_missing_from_in_headers_signature() {
364        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=Subject:A:B; bh=hash; b=hash
365        "#;
366        assert_eq!(
367            validate_header(header).unwrap_err(),
368            DKIMError::FromFieldNotSigned
369        );
370    }
371
372    #[test]
373    fn test_validate_header_expired_in_drift() {
374        let mut now = chrono::Utc::now().naive_utc();
375        now -= chrono::Duration::seconds(1);
376
377        let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp());
378
379        assert!(validate_header(&header).is_ok());
380    }
381
382    #[test]
383    fn test_validate_header_expired() {
384        let mut now = chrono::Utc::now().naive_utc();
385        now -= chrono::Duration::hours(3);
386
387        let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp());
388
389        assert_eq!(
390            validate_header(&header).unwrap_err(),
391            DKIMError::SignatureExpired
392        );
393    }
394
395    #[tokio::test]
396    async fn test_validate_email_header_ed25519() {
397        let raw_email = r#"DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
398 d=football.example.com; i=@football.example.com;
399 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
400 subject : date : message-id : from : subject : date;
401 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
402 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
403 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
404DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
405 d=football.example.com; i=@football.example.com;
406 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
407 date : message-id : from : subject : date;
408 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
409 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
410 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
411 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
412From: Joe SixPack <joe@football.example.com>
413To: Suzie Q <suzie@shopping.example.net>
414Subject: Is dinner ready?
415Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
416Message-ID: <20030712040037.46341.5F8J@football.example.com>
417
418Hi.
419
420We lost the game.  Are you hungry yet?
421
422Joe."#
423            .replace('\n', "\r\n");
424
425        let email = mailparse::parse_mail(raw_email.as_bytes()).unwrap();
426        let h = email
427            .headers
428            .get_all_headers(HEADER)
429            .first()
430            .unwrap()
431            .get_value_raw();
432        let raw_header_dkim = String::from_utf8_lossy(h);
433
434        let resolver: Arc<dyn Lookup> = Arc::new(MockResolver::new());
435
436        let dkim_verify_result = verify_email_header(
437            &slog::Logger::root(slog::Discard, slog::o!()),
438            Arc::clone(&resolver),
439            &validate_header(&raw_header_dkim).unwrap(),
440            &email,
441        )
442        .await;
443
444        assert!(dkim_verify_result.is_ok());
445    }
446
447    #[tokio::test]
448    async fn test_validate_email_header_rsa() {
449        // unfortunately the original RFC spec had a typo, and the mail content differs
450        // between algorithms
451        // https://www.rfc-editor.org/errata_search.php?rfc=6376&rec_status=0
452        let raw_email =
453            r#"DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
454 c=simple/simple; d=example.com;
455 h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com;
456 s=newengland; t=1615825284; v=1;
457 b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G
458 k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g
459 s4wwFRRKz/1bksZGSjD8uuSU=
460Received: from client1.football.example.com  [192.0.2.1]
461      by submitserver.example.com with SUBMISSION;
462      Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
463From: Joe SixPack <joe@football.example.com>
464To: Suzie Q <suzie@shopping.example.net>
465Subject: Is dinner ready?
466Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
467Message-ID: <20030712040037.46341.5F8J@football.example.com>
468
469Hi.
470
471We lost the game. Are you hungry yet?
472
473Joe.
474"#
475            .replace('\n', "\r\n");
476        let email = mailparse::parse_mail(raw_email.as_bytes()).unwrap();
477        let h = email
478            .headers
479            .get_all_headers(HEADER)
480            .first()
481            .unwrap()
482            .get_value_raw();
483        let raw_header_rsa = String::from_utf8_lossy(h);
484
485        let resolver: Arc<dyn Lookup> = Arc::new(MockResolver::new());
486
487        let dkim_verify_result = verify_email_header(
488            &slog::Logger::root(slog::Discard, slog::o!()),
489            Arc::clone(&resolver),
490            &validate_header(&raw_header_rsa).unwrap(),
491            &email,
492        )
493        .await;
494
495        assert!(dkim_verify_result.is_ok());
496    }
497}