auth dns over atproto
at main 158 lines 4.5 kB view raw
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}