anya_core/bip/
dns_resolver.rs

1// [AIR-3][AIS-3][BPC-3][AIT-3] BIP353 DNS Resolver Implementation
2
3use hickory_resolver::{
4    config::{ResolverConfig, ResolverOpts},
5    error::ResolveError,
6    proto::rr::Name,
7    TokioAsyncResolver,
8};
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use thiserror::Error;
13use tokio::time::timeout;
14use tracing::{debug, error};
15
16// BIP353 record format: _bitcoin._wallet.example.org
17const BITCOIN_SERVICE: &str = "_bitcoin";
18const WALLET_SERVICE: &str = "_wallet";
19const DNS_TIMEOUT_SECS: u64 = 5;
20
21/// DNS Resolver errors for BIP353
22#[derive(Error, Debug)]
23pub enum DnsResolverError {
24    #[error("DNS resolution error: {0}")]
25    Resolution(String),
26
27    #[error("Timeout error: {0}")]
28    Timeout(String),
29
30    #[error("DNSSEC validation error: {0}")]
31    DnssecValidation(String),
32
33    #[error("Record format error: {0}")]
34    RecordFormat(String),
35
36    #[error("No valid TXT records found")]
37    NoValidRecords,
38
39    #[error("DNS resolver initialization error: {0}")]
40    Initialization(String),
41}
42
43/// Result type for DNS operations
44pub type DnsResult<T> = Result<T, DnsResolverError>;
45
46/// Cache entry for DNS resolution
47struct CacheEntry {
48    pub txt_records: Vec<String>,
49    pub expires_at: u64,
50    #[allow(dead_code)]
51    pub is_secure: bool,
52}
53
54/// DNS Resolver for BIP353
55pub struct DnsResolver {
56    resolver: TokioAsyncResolver,
57    cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
58    validate_dnssec: bool,
59    cache_duration: u64, // in seconds
60}
61
62impl DnsResolver {
63    /// Create a new DNS resolver
64    pub async fn new(validate_dnssec: bool, cache_duration: u64) -> DnsResult<Self> {
65        let mut opts = ResolverOpts::default();
66        opts.validate = validate_dnssec;
67
68        // TokioAsyncResolver::tokio returns the resolver directly in hickory-resolver
69        let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
70
71        Ok(Self {
72            resolver,
73            cache: Arc::new(Mutex::new(HashMap::new())),
74            validate_dnssec,
75            cache_duration,
76        })
77    }
78
79    /// Resolve a payment username
80    pub async fn resolve_payment_username(
81        &self,
82        user: &str,
83        domain: &str,
84    ) -> DnsResult<Vec<String>> {
85        // Check cache first
86        let cache_key = format!("{user}@{domain}");
87
88        // Check for cached entry
89        if let Some(cached) = self.check_cache(&cache_key) {
90            debug!("Using cached DNS records for {}", cache_key);
91            return Ok(cached);
92        }
93
94        // Format the DNS name for BIP353
95        let name = self.format_dns_name(user, domain)?;
96
97        debug!("Resolving DNS TXT records for {}", name);
98
99        // Query DNS with timeout
100        let lookup_future = self.resolver.txt_lookup(name.clone());
101        let lookup_result =
102            match timeout(Duration::from_secs(DNS_TIMEOUT_SECS), lookup_future).await {
103                Ok(result) => result.map_err(|e| DnsResolverError::Resolution(e.to_string())),
104                Err(_) => Err(DnsResolverError::Timeout(format!(
105                    "DNS lookup for {name} timed out"
106                ))),
107            }?;
108
109        // Process TXT records
110        let mut txt_records = Vec::new();
111        let mut is_secure = false;
112
113        // Use trust-dns-resolver to get the TXT records
114        for record in lookup_result.iter() {
115            for txt in record.iter() {
116                let txt_data = String::from_utf8_lossy(txt).to_string();
117                debug!("Found TXT record: {}", txt_data);
118                txt_records.push(txt_data);
119            }
120        }
121
122        // Check for DNSSEC if validation required
123        if self.validate_dnssec {
124            // For DNSSEC validation, we assume the resolver handles it
125            // The trust-dns-resolver will automatically validate if configured
126            debug!("DNSSEC validation enabled for {}", cache_key);
127            is_secure = true; // trust-dns handles DNSSEC internally when validate=true
128        }
129
130        if txt_records.is_empty() {
131            return Err(DnsResolverError::NoValidRecords);
132        }
133
134        // Cache the results
135        self.cache_result(&cache_key, txt_records.clone(), is_secure);
136
137        Ok(txt_records)
138    }
139
140    /// Format the DNS name according to BIP353
141    fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name> {
142        // Format: _bitcoin._wallet.<user>.<domain>
143        let dns_name = format!("{BITCOIN_SERVICE}.{WALLET_SERVICE}.{user}.{domain}");
144
145        Name::from_ascii(&dns_name).map_err(|e| DnsResolverError::RecordFormat(e.to_string()))
146    }
147
148    /// Check the cache for existing results
149    fn check_cache(&self, cache_key: &str) -> Option<Vec<String>> {
150        let cache = self.cache.lock().unwrap();
151
152        if let Some(entry) = cache.get(cache_key) {
153            let now = SystemTime::now()
154                .duration_since(UNIX_EPOCH)
155                .unwrap()
156                .as_secs();
157
158            if entry.expires_at > now {
159                return Some(entry.txt_records.clone());
160            }
161        }
162
163        None
164    }
165
166    /// Cache the resolution result
167    fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool) {
168        let now = SystemTime::now()
169            .duration_since(UNIX_EPOCH)
170            .unwrap()
171            .as_secs();
172
173        let expires_at = now + self.cache_duration;
174
175        let entry = CacheEntry {
176            txt_records,
177            expires_at,
178            is_secure,
179        };
180
181        let mut cache = self.cache.lock().unwrap();
182        cache.insert(cache_key.to_string(), entry);
183    }
184
185    /// Clear the cache
186    pub fn clear_cache(&self) {
187        let mut cache = self.cache.lock().unwrap();
188        cache.clear();
189    }
190
191    /// Update resolver configuration
192    pub fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()> {
193        // Only recreate resolver if DNSSEC setting changed
194        if self.validate_dnssec != validate_dnssec {
195            let mut opts = ResolverOpts::default();
196            opts.validate = validate_dnssec;
197
198            // TokioAsyncResolver::tokio returns the resolver directly in hickory-resolver
199            let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
200
201            self.resolver = resolver;
202            self.validate_dnssec = validate_dnssec;
203        }
204
205        self.cache_duration = cache_duration;
206
207        // Clear the cache when configuration changes
208        self.clear_cache();
209
210        Ok(())
211    }
212}
213
214/// Parse BIP353 encoded payment instruction
215pub fn parse_payment_instruction(txt_record: &str) -> Option<String> {
216    // According to BIP353, the format should be:
217    // bitcoin=<payment-instruction>
218
219    txt_record
220        .strip_prefix("bitcoin=")
221        .map(|value| value.to_string())
222}
223
224impl From<ResolveError> for DnsResolverError {
225    fn from(err: ResolveError) -> Self {
226        DnsResolverError::Resolution(err.to_string())
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use mockall::predicate::*;
234    use mockall::*;
235
236    // Mock DNS resolver for testing
237    mock! {
238        DnsResolver {
239            async fn resolve_payment_username(&self, user: &str, domain: &str) -> DnsResult<Vec<String>>;
240            fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name>;
241            fn check_cache(&self, cache_key: &str) -> Option<Vec<String>>;
242            fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool);
243            fn clear_cache(&self);
244            fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()>;
245        }
246    }
247
248    #[tokio::test]
249    async fn test_parse_payment_instruction() {
250        let valid_txt = "bitcoin=lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns";
251        let invalid_txt = "not_bitcoin=something";
252
253        assert_eq!(
254            parse_payment_instruction(valid_txt),
255            Some("lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns".to_string())
256        );
257
258        assert_eq!(parse_payment_instruction(invalid_txt), None);
259    }
260
261    #[tokio::test]
262    async fn test_format_dns_name() {
263        // We'll use the real implementation for this test
264        let resolver = DnsResolver {
265            resolver: TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
266            cache: Arc::new(Mutex::new(HashMap::new())),
267            validate_dnssec: false,
268            cache_duration: 3600,
269        };
270
271        let name = resolver.format_dns_name("alice", "example.org").unwrap();
272        assert_eq!(name.to_string(), "_bitcoin._wallet.alice.example.org");
273    }
274}