use std::time::Duration;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use x509_parser::prelude::FromDer as _;
use x509_parser::x509::SubjectPublicKeyInfo;
use crate::dns::{DnsResolver, RecordData, RecordType};
use crate::error::Result;
use crate::validation::normalize_domain;
pub const DKIM_SELECTORS: &[&str] = &[
"default",
"dkim",
"everlytickey1",
"everlytickey2",
"google",
"k1",
"k2",
"k3",
"mail",
"mandrill",
"mxvault",
"protonmail",
"s1",
"s2",
"selector1",
"selector2",
"sig1",
"smtp",
"zoho",
];
pub const DEFAULT_DKIM_DEADLINE: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DkimKeyType {
Rsa,
Ed25519,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum SelectorState {
Absent,
Unknown { reason: String },
Revoked,
Key {
key_type: DkimKeyType,
#[serde(skip_serializing_if = "Option::is_none")]
bits: Option<u32>,
testing: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DkimSelector {
pub selector: String,
#[serde(flatten)]
pub state: SelectorState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DkimReport {
pub domain: String,
pub probed: usize,
pub selectors: Vec<DkimSelector>,
}
pub async fn probe_dkim(
resolver: &DnsResolver,
domain: &str,
deadline: Duration,
) -> Result<DkimReport> {
let domain = normalize_domain(domain)?;
let queries = DKIM_SELECTORS.iter().map(|selector| {
let name = format!("{selector}._domainkey.{domain}");
async move {
let state = match tokio::time::timeout(
deadline,
resolver.resolve(&name, RecordType::TXT, None),
)
.await
{
Err(_) => SelectorState::Unknown {
reason: "deadline exceeded".to_string(),
},
Ok(Err(e)) => {
tracing::debug!(%name, error = %e, "dkim selector query failed");
SelectorState::Unknown {
reason: e.sanitized_message(),
}
}
Ok(Ok(records)) => records
.iter()
.filter_map(|r| match &r.data {
RecordData::TXT { text, .. } => parse_record(text),
_ => None,
})
.next()
.unwrap_or(SelectorState::Absent),
};
DkimSelector {
selector: (*selector).to_string(),
state,
}
}
});
let selectors = futures::future::join_all(queries)
.await
.into_iter()
.filter(|s| s.state != SelectorState::Absent)
.collect();
Ok(DkimReport {
domain,
probed: DKIM_SELECTORS.len(),
selectors,
})
}
pub(crate) fn parse_record(txt: &str) -> Option<SelectorState> {
let mut key_type = DkimKeyType::Rsa;
let mut public_key: Option<String> = None;
let mut testing = false;
for tag in txt.split(';') {
let Some((name, value)) = tag.split_once('=') else {
continue;
};
let value: String = value.chars().filter(|c| !c.is_whitespace()).collect();
match name.trim().to_ascii_lowercase().as_str() {
"k" => {
key_type = match value.to_ascii_lowercase().as_str() {
"rsa" => DkimKeyType::Rsa,
"ed25519" => DkimKeyType::Ed25519,
_ => DkimKeyType::Other,
}
}
"p" => public_key = Some(value),
"t" => testing = value.split(':').any(|flag| flag.eq_ignore_ascii_case("y")),
_ => {}
}
}
let public_key = public_key?;
if public_key.is_empty() {
return Some(SelectorState::Revoked);
}
let der = base64::engine::general_purpose::STANDARD
.decode(public_key.as_bytes())
.ok();
let bits = match key_type {
DkimKeyType::Rsa => der.as_deref().and_then(rsa_bits),
DkimKeyType::Ed25519 => der.filter(|k| k.len() == 32).map(|_| 256),
DkimKeyType::Other => None,
};
Some(SelectorState::Key {
key_type,
bits,
testing,
})
}
fn rsa_bits(der: &[u8]) -> Option<u32> {
let (_, spki) = SubjectPublicKeyInfo::from_der(der).ok()?;
crate::ssl::extract_key_info(&spki).1
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
use hickory_resolver::proto::op::ResponseCode;
use hickory_resolver::proto::rr::rdata as wire;
use hickory_resolver::proto::rr::{RData, RecordType as WireType};
const RSA_512: &str = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAOeQ6SWKJlm1QOVUHTi4A8QtjWZLZ9owHWx00T838aweoQzlqmNou2ZW+eVqC+am0EQtsYGL43Sashay0OvjD8cCAwEAAQ==";
const RSA_1024: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+as2gvWHcF9xJ5MsGInI1t6YVgMf8n48Ngb1VhLRDlxfqIB3GFzE8WV9wMAWqrL4yQFSk5ICoEOKnvodzxH+eU89mNnwHZ9GIjnIP1L753zDugwE2moH81tXJolqTeQGWtv4Ea/S8PKZXgiTGojqo41WEhzWq+RX3+TIxzcHAvQIDAQAB";
const RSA_2048: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArkP2vb3GUcpty40YUqoTQxV65uIwjl/Yx/+5ghSXTSF2lxs6U24rhMdp+2qxZ7kF9erXJ/WYjz70jtFVVAu/dYjI1P+veDIevfWuEhl0kwRdM0RhQin0hJEGIa0RPepEz3zm5W1Wm5SWMFb9X9CuSdOL1z1m1lWZDM6kE34ajTK9Z/5NRtmykyA3Qn7YuVlyMMNRa+6MefUt2QPCuP2c4/UKnNGSpoFZZdzX06uuozipASjQ0H5FvbNvb/OEmpa0+rGgLCaVKn9gkjsNPRGR5StWEKzHT+eIqKf/NJV3tqiwKJ227zsiA0v1lqqy4z56e0FG4RES3+NpZUMBAWoSrQIDAQAB";
fn txt(value: &str) -> MockReply {
let chunks = value
.as_bytes()
.chunks(255)
.map(|c| String::from_utf8(c.to_vec()).unwrap())
.collect();
MockReply::Answer(vec![RData::TXT(wire::TXT::new(chunks))])
}
fn key(state: &SelectorState) -> (DkimKeyType, Option<u32>, bool) {
match state {
SelectorState::Key {
key_type,
bits,
testing,
} => (*key_type, *bits, *testing),
other => panic!("expected a key, got {other:?}"),
}
}
#[test]
fn selector_table_is_sorted_and_unique() {
let mut sorted = DKIM_SELECTORS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted, DKIM_SELECTORS);
}
#[test]
fn parses_rsa_key_sizes() {
for (b64, bits) in [(RSA_512, 512), (RSA_1024, 1024), (RSA_2048, 2048)] {
let state = parse_record(&format!("v=DKIM1; k=rsa; p={b64}")).unwrap();
assert_eq!(key(&state), (DkimKeyType::Rsa, Some(bits), false));
}
}
#[test]
fn rsa_is_the_default_key_type_and_t_y_is_testing() {
let state = parse_record(&format!("v=DKIM1; t=y:s; p={RSA_2048}")).unwrap();
assert_eq!(key(&state), (DkimKeyType::Rsa, Some(2048), true));
}
#[test]
fn ed25519_empty_p_and_non_dkim_records() {
let ed = parse_record("v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
.unwrap();
assert_eq!(key(&ed), (DkimKeyType::Ed25519, Some(256), false));
assert_eq!(parse_record("v=DKIM1; p="), Some(SelectorState::Revoked));
assert_eq!(parse_record("v=spf1 -all"), None);
}
#[test]
fn undecodable_rsa_key_has_unknown_size() {
let state = parse_record("v=DKIM1; k=rsa; p=not-base64!!").unwrap();
assert_eq!(key(&state), (DkimKeyType::Rsa, None, false));
}
#[tokio::test]
async fn probes_selectors_and_reports_found_and_unknown() {
let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
("selector1._domainkey.mail.test", WireType::TXT) => {
txt(&format!("v=DKIM1; k=rsa; p={RSA_2048}"))
}
("google._domainkey.mail.test", WireType::TXT) => {
txt(&format!("v=DKIM1; k=rsa; p={RSA_1024}"))
}
("k1._domainkey.mail.test", WireType::TXT) => MockReply::Rcode(ResponseCode::ServFail),
_ => MockReply::NxDomain,
})
.await;
let report = probe_dkim(
&mock_dns_resolver_default(port),
"mail.test",
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(report.probed, DKIM_SELECTORS.len());
let by_name = |n: &str| {
report
.selectors
.iter()
.find(|s| s.selector == n)
.map(|s| s.state.clone())
};
assert_eq!(key(&by_name("selector1").unwrap()).1, Some(2048));
assert_eq!(key(&by_name("google").unwrap()).1, Some(1024));
assert!(matches!(by_name("k1"), Some(SelectorState::Unknown { .. })));
assert_eq!(by_name("s1"), None, "absent selectors are not listed");
}
}