kumo_dkim/
public_key.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
use crate::{parser, DKIMError, DkimPublicKey, DNS_NAMESPACE};
use dns_resolver::Resolver;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use std::collections::HashMap;

const RSA_KEY_TYPE: &str = "rsa";
const ED25519_KEY_TYPE: &str = "ed25519";

// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
pub(crate) async fn retrieve_public_key(
    resolver: &dyn Resolver,
    domain: &str,
    subdomain: &str,
) -> Result<DkimPublicKey, DKIMError> {
    let dns_name = format!("{}.{}.{}", subdomain, DNS_NAMESPACE, domain);
    let answer = resolver.resolve_txt(&dns_name).await?;
    if answer.records.is_empty() {
        return Err(DKIMError::KeyUnavailable(format!(
            "failed to resolve {dns_name}"
        )));
    }

    // TODO: Return multiple keys for when verifiying the signatures. During key
    // rotation they are often multiple keys to consider.
    let txt = answer.as_txt();
    let first = txt.first().ok_or(DKIMError::NoKeyForSignature)?;
    tracing::debug!("DKIM TXT: {:?}", first);

    // Parse the tags inside the DKIM TXT DNS record
    let (_, tags) = parser::tag_list(first).map_err(|err| {
        tracing::warn!("key syntax error: {}", err);
        DKIMError::KeySyntaxError
    })?;

    let mut tags_map = HashMap::new();
    for tag in &tags {
        tags_map.insert(tag.name.clone(), tag.clone());
    }

    // Check version
    if let Some(version) = tags_map.get("v") {
        if version.value != "DKIM1" {
            return Err(DKIMError::KeyIncompatibleVersion);
        }
    }

    // Get key type
    let key_type = match tags_map.get("k") {
        Some(v) => {
            if v.value != RSA_KEY_TYPE && v.value != ED25519_KEY_TYPE {
                return Err(DKIMError::InappropriateKeyAlgorithm);
            }
            v.value.clone()
        }
        None => RSA_KEY_TYPE.to_string(),
    };

    let tag = tags_map.get("p").ok_or(DKIMError::NoKeyForSignature)?;
    let bytes = data_encoding::BASE64
        .decode(tag.value.as_bytes())
        .map_err(|err| {
            DKIMError::KeyUnavailable(format!("failed to decode public key: {}", err))
        })?;
    let key = if key_type == RSA_KEY_TYPE {
        DkimPublicKey::Rsa(
            PKey::from_rsa(
                Rsa::public_key_from_der(&bytes)
                    .or_else(|_| Rsa::public_key_from_der_pkcs1(&bytes))
                    .map_err(|err| {
                        DKIMError::KeyUnavailable(format!("failed to parse public key: {}", err))
                    })?,
            )
            .map_err(|err| {
                DKIMError::KeyUnavailable(format!("failed to parse public key: {}", err))
            })?,
        )
    } else {
        let mut key_bytes = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
        if bytes.len() != key_bytes.len() {
            return Err(DKIMError::KeyUnavailable(format!(
                "ed25519 public keys should be {} bytes in length, have: {}",
                ed25519_dalek::PUBLIC_KEY_LENGTH,
                bytes.len()
            )));
        }

        key_bytes.copy_from_slice(&bytes);

        DkimPublicKey::Ed25519(ed25519_dalek::VerifyingKey::from_bytes(&key_bytes).map_err(
            |err| DKIMError::KeyUnavailable(format!("failed to parse public key: {}", err)),
        )?)
    };
    Ok(key)
}

#[cfg(test)]
mod tests {
    use super::*;
    use dns_resolver::TestResolver;

    #[tokio::test]
    async fn test_retrieve_public_key() {
        let resolver = TestResolver::default()
            .with_txt(
                "dkim._domainkey.cloudflare.com",
                "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6gmVDBSBJ0l1/33uAF0gwIsrjQV6nnYjL9DMX6+ez4NNJ2um0InYy128Rd+OlIhmdSld6g3tj3O6R+BwsYsQgU8RWE8VJaRybvPw2P3Asgms4uPrFWHSFiWMPH0P9i/oPwnUO9jZKHiz4+MzFC3bG8BacX7YIxCuWnDU8XNmNsRaLmrv9CHX4/3GHyoHSmDA1ETtyz9JHRCOC8ho8C7b4f2Auwedlau9Lid9LGBhozhgRFhrFwFMe93y34MO1clPbY6HwxpudKWBkMQCTlmXVRnkKxHlJ+fYCyC2jjpCIbGWj2oLxBtFOASWMESR4biW0ph2bsZXslcUSPMTVTkFxQIDAQAB".to_owned(),
            );

        retrieve_public_key(&resolver, "cloudflare.com", "dkim")
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn test_retrieve_public_key_incompatible_version() {
        let resolver = TestResolver::default().with_txt(
            "dkim._domainkey.cloudflare.com",
            "v=DKIM6; p=key".to_owned(),
        );

        let key = retrieve_public_key(&resolver, "cloudflare.com", "dkim")
            .await
            .unwrap_err();
        assert_eq!(key, DKIMError::KeyIncompatibleVersion);
    }

    #[tokio::test]
    async fn test_retrieve_public_key_inappropriate_key_algorithm() {
        let resolver = TestResolver::default().with_txt(
            "dkim._domainkey.cloudflare.com",
            "v=DKIM1; p=key; k=foo".to_owned(),
        );

        let key = retrieve_public_key(&resolver, "cloudflare.com", "dkim")
            .await
            .unwrap_err();
        assert_eq!(key, DKIMError::InappropriateKeyAlgorithm);
    }
}