auth dns over atproto
1use std::net::IpAddr;
2
3use anyhow::{Context, Result};
4use hickory_resolver::config::{NameServerConfigGroup, ResolverConfig};
5use hickory_resolver::name_server::TokioConnectionProvider;
6use hickory_resolver::TokioResolver;
7
8pub struct Checker {
9 expected_ns: Vec<String>,
10 nameservers: Option<Vec<IpAddr>>,
11 dns_port: u16,
12}
13
14pub struct CheckResult {
15 pub verified: bool,
16 pub ns_records: Vec<String>,
17 pub txt_match: bool,
18}
19
20impl Checker {
21 pub fn new(expected_ns: Vec<String>, nameservers: Option<Vec<IpAddr>>, dns_port: u16) -> Self {
22 let expected_ns = expected_ns
23 .into_iter()
24 .map(|ns| ns.to_lowercase().trim_end_matches('.').to_string())
25 .collect();
26
27 Self {
28 expected_ns,
29 nameservers,
30 dns_port,
31 }
32 }
33
34 fn build_resolver(&self) -> Result<TokioResolver> {
35 let resolver = match &self.nameservers {
36 Some(addrs) => {
37 let ns_group = NameServerConfigGroup::from_ips_clear(addrs, self.dns_port, true);
38 let config = ResolverConfig::from_parts(None, vec![], ns_group);
39 TokioResolver::builder_with_config(config, TokioConnectionProvider::default())
40 .build()
41 }
42 None => TokioResolver::builder_tokio()
43 .context("failed to read system resolver config")?
44 .build(),
45 };
46
47 Ok(resolver)
48 }
49
50 pub async fn check_zone(&self, zone: &str, did: &str) -> Result<CheckResult> {
51 let resolver = self.build_resolver()?;
52
53 let fqdn = if zone.ends_with('.') {
54 zone.to_string()
55 } else {
56 format!("{zone}.")
57 };
58
59 let ns_lookup = resolver
60 .ns_lookup(&fqdn)
61 .await
62 .context("NS lookup failed")?;
63
64 let ns_records: Vec<String> = ns_lookup
65 .iter()
66 .map(|ns| ns.to_string().to_lowercase().trim_end_matches('.').to_string())
67 .collect();
68
69 tracing::debug!(
70 zone = %zone,
71 ns = ?ns_records,
72 "NS lookup result"
73 );
74
75 let ns_valid = self.expected_ns.iter().all(|expected| {
76 ns_records.iter().any(|actual| actual == expected)
77 });
78
79 let txt_name = format!("_onis-verify.{fqdn}");
80 let txt_match = match resolver.txt_lookup(&txt_name).await {
81 Ok(txt_lookup) => txt_lookup.iter().any(|txt| {
82 let value: String = txt
83 .txt_data()
84 .iter()
85 .map(|d| String::from_utf8_lossy(d))
86 .collect();
87 value.trim() == did
88 }),
89 Err(e) => {
90 tracing::debug!(
91 zone = %zone,
92 name = %txt_name,
93 error = %e,
94 "TXT ownership lookup failed or no record found"
95 );
96 false
97 }
98 };
99
100 tracing::debug!(
101 zone = %zone,
102 ns_valid,
103 txt_match,
104 did = %did,
105 "verification check result"
106 );
107
108 let verified = ns_valid && txt_match;
109
110 Ok(CheckResult {
111 verified,
112 ns_records,
113 txt_match,
114 })
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn new_normalizes_trailing_dots() {
124 let checker = Checker::new(
125 vec![
126 "ns1.example.com.".to_string(),
127 "ns2.example.com.".to_string(),
128 ],
129 None,
130 53,
131 );
132 assert_eq!(checker.expected_ns, vec!["ns1.example.com", "ns2.example.com"]);
133 }
134
135 #[test]
136 fn new_normalizes_to_lowercase() {
137 let checker = Checker::new(vec!["NS1.Example.COM".to_string()], None, 53);
138 assert_eq!(checker.expected_ns, vec!["ns1.example.com"]);
139 }
140
141 #[test]
142 fn new_normalizes_case_and_dots() {
143 let checker = Checker::new(vec!["NS1.Example.COM.".to_string()], None, 53);
144 assert_eq!(checker.expected_ns, vec!["ns1.example.com"]);
145 }
146
147 #[test]
148 fn new_empty_expected_ns() {
149 let checker = Checker::new(vec![], None, 53);
150 assert!(checker.expected_ns.is_empty());
151 }
152
153 #[test]
154 fn new_preserves_dns_port() {
155 let checker = Checker::new(vec![], None, 5353);
156 assert_eq!(checker.dns_port, 5353);
157 }
158}