kumo_dkim/
lib.rs

1// Implementation of DKIM: https://datatracker.ietf.org/doc/html/rfc6376
2
3use crate::errors::Status;
4use crate::hash::HeaderList;
5use dns_resolver::{HickoryResolver, Resolver};
6use ed25519_dalek::pkcs8::DecodePrivateKey;
7use ed25519_dalek::SigningKey;
8use mailparsing::AuthenticationResult;
9use openssl::md::Md;
10use openssl::pkey::PKey;
11use openssl::pkey_ctx::PkeyCtx;
12use openssl::rsa::{Padding, Rsa};
13use std::collections::BTreeMap;
14
15pub mod canonicalization;
16mod errors;
17mod hash;
18mod header;
19mod parsed_email;
20mod parser;
21mod public_key;
22#[cfg(test)]
23mod roundtrip_test;
24mod sign;
25
26pub use errors::DKIMError;
27use header::{DKIMHeader, HEADER};
28pub use parsed_email::ParsedEmail;
29pub use parser::{tag_list as parse_tag_list, Tag};
30pub use sign::{Signer, SignerBuilder};
31
32const DNS_NAMESPACE: &str = "_domainkey";
33
34#[derive(Debug)]
35pub(crate) enum DkimPublicKey {
36    Rsa(PKey<openssl::pkey::Public>),
37    Ed25519(ed25519_dalek::VerifyingKey),
38}
39
40#[allow(clippy::large_enum_variant)]
41#[derive(Debug)]
42pub enum DkimPrivateKey {
43    Ed25519(SigningKey),
44    OpenSSLRsa(Rsa<openssl::pkey::Private>),
45}
46
47impl DkimPrivateKey {
48    /// Parse RSA key data into a DkimPrivateKey
49    pub fn rsa_key(data: &[u8]) -> Result<Self, DKIMError> {
50        let mut errors = vec![];
51
52        match Rsa::private_key_from_pem(data) {
53            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
54            Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
55        };
56        match Rsa::private_key_from_der(data) {
57            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
58            Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
59        };
60
61        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
62    }
63
64    /// Load RSA key data from a file and parse it into a DkimPrivateKey
65    pub fn rsa_key_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, DKIMError> {
66        let path = path.as_ref();
67        let data = std::fs::read(path).map_err(|err| {
68            DKIMError::PrivateKeyLoadError(format!(
69                "rsa_key_file: failed to read file {path:?}: {err:#}"
70            ))
71        })?;
72        Self::rsa_key(&data)
73    }
74
75    /// Parse PKCS8 encoded ed25519 key data into a DkimPrivateKey.
76    /// Both DER and PEM are supported
77    pub fn ed25519_key(data: &[u8]) -> Result<Self, DKIMError> {
78        let mut errors = vec![];
79
80        match SigningKey::from_pkcs8_der(data) {
81            Ok(key) => return Ok(Self::Ed25519(key)),
82            Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
83        }
84
85        match std::str::from_utf8(data) {
86            Ok(s) => match SigningKey::from_pkcs8_pem(s) {
87                Ok(key) => return Ok(Self::Ed25519(key)),
88                Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
89            },
90            Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
91        }
92
93        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
94    }
95
96    /// Parse either an RSA or ed25519 key into a DkimPrivateKey.
97    /// Both DER and PEM are supported.
98    pub fn key(data: &[u8]) -> Result<Self, DKIMError> {
99        let mut errors = vec![];
100
101        match Rsa::private_key_from_pem(data) {
102            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
103            Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
104        }
105        match Rsa::private_key_from_der(data) {
106            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
107            Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
108        }
109        match SigningKey::from_pkcs8_der(data) {
110            Ok(key) => return Ok(Self::Ed25519(key)),
111            Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
112        }
113        match std::str::from_utf8(data) {
114            Ok(s) => match SigningKey::from_pkcs8_pem(s) {
115                Ok(key) => return Ok(Self::Ed25519(key)),
116                Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
117            },
118            Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
119        }
120
121        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
122    }
123}
124
125// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 Step 4
126fn verify_signature(
127    hash_algo: hash::HashAlgo,
128    header_hash: &[u8],
129    signature: &[u8],
130    public_key: DkimPublicKey,
131) -> Result<bool, DKIMError> {
132    Ok(match public_key {
133        DkimPublicKey::Rsa(public_key) => {
134            let md = match hash_algo {
135                hash::HashAlgo::RsaSha1 => Md::sha1(),
136                hash::HashAlgo::RsaSha256 => Md::sha256(),
137                hash => return Err(DKIMError::UnsupportedHashAlgorithm(format!("{:?}", hash))),
138            };
139
140            let mut ctx = PkeyCtx::new(&public_key).map_err(|err| {
141                DKIMError::SignatureSyntaxError(format!("Error loading RSA public key: {err}"))
142            })?;
143
144            ctx.verify_init().map_err(|err| {
145                DKIMError::UnknownInternalError(format!("ctx.verify_init failed: {err}"))
146            })?;
147            ctx.set_rsa_padding(Padding::PKCS1).map_err(|err| {
148                DKIMError::UnknownInternalError(format!("ctx.set_rsa_padding failed: {err}"))
149            })?;
150            ctx.set_signature_md(md).map_err(|err| {
151                DKIMError::UnknownInternalError(format!("ctx.set_signature_md failed: {err}"))
152            })?;
153            ctx.verify(header_hash, signature).unwrap_or_default()
154        }
155        DkimPublicKey::Ed25519(public_key) => {
156            let mut sig_bytes = [0u8; ed25519_dalek::Signature::BYTE_SIZE];
157            if signature.len() != sig_bytes.len() {
158                return Err(DKIMError::SignatureSyntaxError(format!(
159                    "ed25519 signatures should be {} bytes in length, have: {}",
160                    ed25519_dalek::Signature::BYTE_SIZE,
161                    signature.len()
162                )));
163            }
164            sig_bytes.copy_from_slice(signature);
165
166            public_key
167                .verify_strict(
168                    header_hash,
169                    &ed25519_dalek::Signature::from_bytes(&sig_bytes),
170                )
171                .is_ok()
172        }
173    })
174}
175
176async fn verify_email_header<'a>(
177    resolver: &dyn Resolver,
178    dkim_header: &'a DKIMHeader,
179    email: &'a ParsedEmail<'a>,
180) -> Result<(), DKIMError> {
181    let public_keys = public_key::retrieve_public_keys(
182        resolver,
183        dkim_header.get_required_tag("d"),
184        dkim_header.get_required_tag("s"),
185    )
186    .await?;
187
188    let (header_canonicalization_type, body_canonicalization_type) =
189        parser::parse_canonicalization(dkim_header.get_tag("c"))?;
190    let hash_algo = parser::parse_hash_algo(dkim_header.get_required_tag("a"))?;
191    let computed_body_hash = hash::compute_body_hash(
192        body_canonicalization_type,
193        dkim_header.parse_tag("l")?,
194        hash_algo,
195        email,
196    )?;
197
198    let header_list: Vec<String> = dkim_header
199        .get_required_tag("h")
200        .split(':')
201        .map(|s| s.trim().to_ascii_lowercase())
202        .collect();
203
204    let computed_headers_hash = hash::compute_headers_hash(
205        header_canonicalization_type,
206        &HeaderList::new(header_list),
207        hash_algo,
208        dkim_header,
209        email,
210    )?;
211    tracing::debug!("body_hash {:?}", computed_body_hash);
212
213    let header_body_hash = dkim_header.get_required_tag("bh");
214    if header_body_hash != computed_body_hash {
215        return Err(DKIMError::BodyHashDidNotVerify);
216    }
217
218    let signature = data_encoding::BASE64
219        .decode(dkim_header.get_required_tag("b").as_bytes())
220        .map_err(|err| {
221            DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
222        })?;
223
224    let mut errors = vec![];
225    for public_key in public_keys {
226        match verify_signature(hash_algo, &computed_headers_hash, &signature, public_key) {
227            Ok(true) => return Ok(()),
228            Ok(false) => {}
229            Err(err) => {
230                errors.push(err);
231            }
232        }
233    }
234
235    if let Some(err) = errors.pop() {
236        // Something definitely failed
237        return Err(err);
238    }
239
240    // There were no errors and all keys returned false from verify_signature().
241    // That means that the signature is not verified.
242    Err(DKIMError::SignatureDidNotVerify)
243}
244
245/// Run the DKIM verification on the email providing an existing resolver
246pub async fn verify_email_with_resolver<'a>(
247    from_domain: &str,
248    email: &'a ParsedEmail<'a>,
249    resolver: &dyn Resolver,
250) -> Result<Vec<AuthenticationResult>, DKIMError> {
251    let mut results = vec![];
252
253    let mut dkim_headers = vec![];
254
255    for h in email.get_headers().iter_named(HEADER) {
256        if results.len() > 10 {
257            // Limit DoS impact if a malicious message is filled
258            // with signatures
259            break;
260        }
261
262        let value = h.get_raw_value();
263        match DKIMHeader::parse(value) {
264            Ok(v) => {
265                dkim_headers.push(v);
266            }
267            Err(err) => {
268                results.push(AuthenticationResult {
269                    method: "dkim".to_string(),
270                    method_version: None,
271                    result: "permerror".to_string(),
272                    reason: Some(format!("{err}")),
273                    props: BTreeMap::new(),
274                });
275            }
276        }
277    }
278
279    /// <https://datatracker.ietf.org/doc/html/rfc6008>
280    /// The value associated with this item in the header field MUST be
281    /// at least the first eight characters of the digital signature
282    /// (the "b=" tag from a DKIM-Signature) for which a result is being
283    /// relayed, and MUST be long enough to be unique among the results being
284    /// reported.
285    fn compute_header_b(b_tag: &str, headers: &[DKIMHeader]) -> String {
286        let mut len = 8;
287
288        'bigger: while len < b_tag.len() {
289            for h in headers {
290                let candidate = h.get_required_tag("b");
291                if candidate == b_tag {
292                    continue;
293                }
294                if b_tag[0..len] == candidate[0..len] {
295                    len += 2;
296                    continue 'bigger;
297                }
298            }
299            return b_tag[0..len].to_string();
300        }
301        b_tag.to_string()
302    }
303
304    for dkim_header in &dkim_headers {
305        let signing_domain = dkim_header.get_required_tag("d");
306        let mut props = BTreeMap::new();
307
308        props.insert("header.d".to_string(), signing_domain.to_string());
309        props.insert("header.i".to_string(), format!("@{signing_domain}"));
310        props.insert(
311            "header.a".to_string(),
312            dkim_header.get_required_tag("a").to_string(),
313        );
314        props.insert(
315            "header.s".to_string(),
316            dkim_header.get_required_tag("s").to_string(),
317        );
318
319        let b_tag = compute_header_b(dkim_header.get_required_tag("b"), &dkim_headers);
320        props.insert("header.b".to_string(), b_tag);
321
322        let mut reason = None;
323        let result = match verify_email_header(resolver, dkim_header, email).await {
324            Ok(()) => {
325                if signing_domain.eq_ignore_ascii_case(from_domain) {
326                    "pass"
327                } else {
328                    let why = "mail-from-mismatch-signing-domain".to_string();
329                    reason.replace(why.clone());
330                    props.insert("policy.dkim-rules".to_string(), why);
331                    "policy"
332                }
333            }
334            Err(err) => {
335                reason.replace(format!("{err}"));
336                match err.status() {
337                    Status::Tempfail => "temperror",
338                    Status::Permfail => "permerror",
339                }
340            }
341        };
342
343        results.push(AuthenticationResult {
344            method: "dkim".to_string(),
345            method_version: None,
346            result: result.to_string(),
347            reason,
348            props,
349        });
350    }
351
352    Ok(results)
353}
354
355/// Run the DKIM verification on the email
356pub async fn verify_email<'a>(
357    from_domain: &str,
358    email: &'a ParsedEmail<'a>,
359) -> Result<Vec<AuthenticationResult>, DKIMError> {
360    let resolver = HickoryResolver::new().map_err(|err| {
361        DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
362    })?;
363
364    verify_email_with_resolver(from_domain, email, &resolver).await
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use dns_resolver::TestResolver;
371
372    const NEW_ENGLAND_DKIM: (&str, &str) = (
373        "newengland._domainkey.example.com",
374        "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE=",
375    );
376
377    #[test]
378    fn test_validate_header() {
379        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
380c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
381t=1117574938; x=9118006938; l=200;
382h=from:to:subject:date:keywords:keywords;
383z=From:foo@eng.example.net|To:joe@example.com|
384Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
385bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
386b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
387      VoG4ZHRNiYzR
388        "#;
389        DKIMHeader::parse(header).unwrap();
390    }
391
392    #[test]
393    fn test_validate_header_missing_tag() {
394        let header = "v=1; a=rsa-sha256; bh=a; b=b";
395        assert_eq!(
396            DKIMHeader::parse(header).unwrap_err(),
397            DKIMError::SignatureMissingRequiredTag("d")
398        );
399    }
400
401    #[test]
402    fn test_validate_header_domain_mismatch() {
403        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
404        "#;
405        assert_eq!(
406            DKIMHeader::parse(header).unwrap_err(),
407            DKIMError::DomainMismatch
408        );
409    }
410
411    #[test]
412    fn test_validate_header_incompatible_version() {
413        let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
414        "#;
415        assert_eq!(
416            DKIMHeader::parse(header).unwrap_err(),
417            DKIMError::IncompatibleVersion
418        );
419    }
420
421    #[test]
422    fn test_validate_header_missing_from_in_headers_signature() {
423        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
424        "#;
425        assert_eq!(
426            DKIMHeader::parse(header).unwrap_err(),
427            DKIMError::FromFieldNotSigned
428        );
429    }
430
431    #[test]
432    fn test_validate_header_expired_in_drift() {
433        let mut now = chrono::Utc::now().naive_utc();
434        now -= chrono::Duration::try_seconds(1).expect("1 second to be valid");
435
436        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.and_utc().timestamp());
437
438        assert!(DKIMHeader::parse(&header).is_ok());
439    }
440
441    #[test]
442    fn test_validate_header_expired() {
443        let mut now = chrono::Utc::now().naive_utc();
444        now -= chrono::Duration::try_hours(3).expect("3 hours to be legit");
445
446        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.and_utc().timestamp());
447
448        assert_eq!(
449            DKIMHeader::parse(&header).unwrap_err(),
450            DKIMError::SignatureExpired
451        );
452    }
453
454    #[tokio::test]
455    async fn test_validate_email_header_ed25519() {
456        let raw_email = r#"DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
457 d=football.example.com; i=@football.example.com;
458 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
459 subject : date : message-id : from : subject : date;
460 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
461 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
462 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
463DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
464 d=football.example.com; i=@football.example.com;
465 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
466 date : message-id : from : subject : date;
467 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
468 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
469 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
470 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
471From: Joe SixPack <joe@football.example.com>
472To: Suzie Q <suzie@shopping.example.net>
473Subject: Is dinner ready?
474Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
475Message-ID: <20030712040037.46341.5F8J@football.example.com>
476
477Hi.
478
479We lost the game.  Are you hungry yet?
480
481Joe."#
482            .replace('\n', "\r\n");
483
484        let email = ParsedEmail::parse(raw_email).unwrap();
485        let raw_header_dkim = email
486            .get_headers()
487            .iter_named(HEADER)
488            .next()
489            .unwrap()
490            .get_raw_value();
491
492        const DKIM_BRISBANE: &str = r#"
493$ORIGIN brisbane._domainkey.football.example.com
494@     300 TXT "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE="
495          TXT "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
496"#;
497
498        let resolver = TestResolver::default().with_zone(DKIM_BRISBANE);
499
500        verify_email_header(
501            &resolver,
502            &DKIMHeader::parse(raw_header_dkim).unwrap(),
503            &email,
504        )
505        .await
506        .unwrap();
507    }
508
509    #[tokio::test]
510    async fn test_validate_email_header_rsa() {
511        // unfortunately the original RFC spec had a typo, and the mail content differs
512        // between algorithms
513        // https://www.rfc-editor.org/errata_search.php?rfc=6376&rec_status=0
514        let raw_email =
515            r#"DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
516 c=simple/simple; d=example.com;
517 h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com;
518 s=newengland; t=1615825284; v=1;
519 b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G
520 k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g
521 s4wwFRRKz/1bksZGSjD8uuSU=
522Received: from client1.football.example.com  [192.0.2.1]
523      by submitserver.example.com with SUBMISSION;
524      Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
525From: Joe SixPack <joe@football.example.com>
526To: Suzie Q <suzie@shopping.example.net>
527Subject: Is dinner ready?
528Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
529Message-ID: <20030712040037.46341.5F8J@football.example.com>
530
531Hi.
532
533We lost the game. Are you hungry yet?
534
535Joe.
536"#
537            .replace('\n', "\r\n");
538        let email = ParsedEmail::parse(raw_email).unwrap();
539        let raw_header_rsa = email
540            .get_headers()
541            .iter_named(HEADER)
542            .next()
543            .unwrap()
544            .get_raw_value();
545
546        let resolver =
547            TestResolver::default().with_txt(NEW_ENGLAND_DKIM.0, NEW_ENGLAND_DKIM.1.to_owned());
548
549        verify_email_header(
550            &resolver,
551            &DKIMHeader::parse(raw_header_rsa).unwrap(),
552            &email,
553        )
554        .await
555        .unwrap();
556    }
557}