A library for ATProtocol identities.
1//! AT Protocol identity resolution for handles and DIDs. 2//! 3//! Resolves AT Protocol identities via DNS TXT records and HTTPS well-known endpoints, 4//! with automatic input detection for handles, did:plc, and did:web identifiers. 5//! - **Validation**: Ensures DNS and HTTP resolution methods agree on the resolved DID 6//! - **Custom DNS**: Supports custom DNS nameservers for resolution 7//! 8//! ## Resolution Flow 9//! 10//! 1. Parse input to determine identifier type (handle vs DID) 11//! 2. For handles: perform parallel DNS and HTTP resolution 12//! 3. Validate that both methods return the same DID 13//! 4. For DIDs: return the identifier directly 14 15use anyhow::Result; 16#[cfg(feature = "hickory-dns")] 17use hickory_resolver::{ 18 Resolver, TokioResolver, 19 config::{NameServerConfigGroup, ResolverConfig}, 20 name_server::TokioConnectionProvider, 21}; 22use reqwest::Client; 23use std::collections::HashSet; 24use std::ops::Deref; 25use std::sync::Arc; 26use std::time::Duration; 27use tracing::{Instrument, instrument}; 28 29use crate::errors::ResolveError; 30use crate::model::Document; 31use crate::plc::query as plc_query; 32use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 33use crate::web::query as web_query; 34 35pub use crate::traits::{DnsResolver, IdentityResolver}; 36 37/// Hickory DNS implementation of the DnsResolver trait. 38/// Wraps hickory_resolver::TokioResolver for TXT record resolution. 39#[cfg(feature = "hickory-dns")] 40#[derive(Clone)] 41pub struct HickoryDnsResolver { 42 resolver: TokioResolver, 43} 44 45#[cfg(feature = "hickory-dns")] 46impl HickoryDnsResolver { 47 /// Creates a new HickoryDnsResolver with the given TokioResolver. 48 pub fn new(resolver: TokioResolver) -> Self { 49 Self { resolver } 50 } 51 52 /// Creates a DNS resolver with custom or system nameservers. 53 /// Uses custom nameservers if provided, otherwise system defaults. 54 pub fn create_resolver(nameservers: &[std::net::IpAddr]) -> Self { 55 // Initialize the DNS resolver with custom nameservers if configured 56 let tokio_resolver = if !nameservers.is_empty() { 57 tracing::debug!("Using custom DNS nameservers: {:?}", nameservers); 58 let nameserver_group = NameServerConfigGroup::from_ips_clear(nameservers, 53, true); 59 let resolver_config = ResolverConfig::from_parts(None, vec![], nameserver_group); 60 Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default()) 61 .build() 62 } else { 63 tracing::debug!("Using system default DNS nameservers"); 64 Resolver::builder_tokio().unwrap().build() 65 }; 66 Self::new(tokio_resolver) 67 } 68} 69 70#[cfg(feature = "hickory-dns")] 71#[async_trait::async_trait] 72impl DnsResolver for HickoryDnsResolver { 73 async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError> { 74 let lookup = self 75 .resolver 76 .txt_lookup(domain) 77 .instrument(tracing::info_span!("txt_lookup")) 78 .await 79 .map_err(|error| ResolveError::DNSResolutionFailed { error })?; 80 81 Ok(lookup.iter().map(|record| record.to_string()).collect()) 82 } 83} 84 85/// Type of input identifier for resolution. 86/// Distinguishes between handles and different DID methods. 87pub enum InputType { 88 /// AT Protocol handle (e.g., "alice.bsky.social"). 89 Handle(String), 90 /// PLC DID identifier (e.g., "did:plc:abc123"). 91 Plc(String), 92 /// Web DID identifier (e.g., "did:web:example.com"). 93 Web(String), 94} 95 96/// Resolves a handle to DID using DNS TXT records. 97/// Looks up _atproto.{handle} TXT record for DID value. 98#[instrument(skip(dns_resolver), err)] 99pub async fn resolve_handle_dns<R: DnsResolver + ?Sized>( 100 dns_resolver: &R, 101 lookup_dns: &str, 102) -> Result<String, ResolveError> { 103 let txt_records = dns_resolver 104 .resolve_txt(&format!("_atproto.{}", lookup_dns)) 105 .await?; 106 107 let dids = txt_records 108 .iter() 109 .filter_map(|record| record.strip_prefix("did=").map(|did| did.to_string())) 110 .collect::<HashSet<String>>(); 111 112 if dids.len() > 1 { 113 return Err(ResolveError::MultipleDIDsFound); 114 } 115 116 dids.iter().next().cloned().ok_or(ResolveError::NoDIDsFound) 117} 118 119/// Resolves a handle to DID using HTTPS well-known endpoint. 120/// Fetches DID from https://{handle}/.well-known/atproto-did 121#[instrument(skip(http_client), err)] 122pub async fn resolve_handle_http( 123 http_client: &reqwest::Client, 124 handle: &str, 125) -> Result<String, ResolveError> { 126 let lookup_url = format!("https://{}/.well-known/atproto-did", handle); 127 128 http_client 129 .get(lookup_url.clone()) 130 .timeout(Duration::from_secs(10)) 131 .send() 132 .instrument(tracing::info_span!("http_client_get")) 133 .await 134 .map_err(|error| ResolveError::HTTPResolutionFailed { error })? 135 .text() 136 .instrument(tracing::info_span!("response_text")) 137 .await 138 .map_err(|error| ResolveError::HTTPResolutionFailed { error }) 139 .and_then(|body| { 140 if body.starts_with("did:") { 141 Ok(body.trim().to_string()) 142 } else { 143 Err(ResolveError::InvalidHTTPResolutionResponse) 144 } 145 }) 146} 147 148/// Parses input string into appropriate identifier type. 149/// Handles prefixes like "at://", "@", and DID formats. 150pub fn parse_input(input: &str) -> Result<InputType, ResolveError> { 151 let trimmed = { 152 if let Some(value) = input.trim().strip_prefix("at://") { 153 value.trim() 154 } else if let Some(value) = input.trim().strip_prefix('@') { 155 value.trim() 156 } else { 157 input.trim() 158 } 159 }; 160 if trimmed.is_empty() { 161 return Err(ResolveError::InvalidInput); 162 } 163 if trimmed.starts_with("did:web:") { 164 Ok(InputType::Web(trimmed.to_string())) 165 } else if trimmed.starts_with("did:plc:") && is_valid_did_method_plc(trimmed) { 166 Ok(InputType::Plc(trimmed.to_string())) 167 } else { 168 is_valid_handle(trimmed) 169 .map(InputType::Handle) 170 .ok_or(ResolveError::InvalidInput) 171 } 172} 173 174#[cfg(test)] 175mod tests { 176 use super::*; 177 use crate::key::{ 178 IdentityDocumentKeyResolver, KeyResolver, KeyType, generate_key, identify_key, to_public, 179 }; 180 use crate::model::{DocumentBuilder, VerificationMethod}; 181 use std::collections::HashMap; 182 183 struct StubIdentityResolver { 184 expected: String, 185 document: Document, 186 } 187 188 #[async_trait::async_trait] 189 impl IdentityResolver for StubIdentityResolver { 190 async fn resolve(&self, subject: &str) -> Result<Document> { 191 if !self.expected.is_empty() { 192 assert_eq!(self.expected, subject); 193 } 194 Ok(self.document.clone()) 195 } 196 } 197 198 #[tokio::test] 199 async fn resolves_direct_did_key() -> Result<()> { 200 let private_key = generate_key(KeyType::K256Private)?; 201 let public_key = to_public(&private_key)?; 202 let key_reference = format!("{}", &public_key); 203 204 let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 205 expected: String::new(), 206 document: Document::builder() 207 .id("did:plc:placeholder") 208 .build() 209 .unwrap(), 210 })); 211 212 let key_data = resolver.resolve(&key_reference).await?; 213 assert_eq!(key_data.bytes(), public_key.bytes()); 214 Ok(()) 215 } 216 217 #[tokio::test] 218 async fn resolves_literal_did_key_reference() -> Result<()> { 219 let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 220 expected: String::new(), 221 document: Document::builder() 222 .id("did:example:unused".to_string()) 223 .build() 224 .unwrap(), 225 })); 226 227 let sample = "did:key:zDnaezRmyM3NKx9NCphGiDFNBEMyR2sTZhhMGTseXCU2iXn53"; 228 let expected = identify_key(sample)?; 229 let resolved = resolver.resolve(sample).await?; 230 assert_eq!(resolved.bytes(), expected.bytes()); 231 Ok(()) 232 } 233 234 #[tokio::test] 235 async fn resolves_via_identity_document() -> Result<()> { 236 let private_key = generate_key(KeyType::P256Private)?; 237 let public_key = to_public(&private_key)?; 238 let public_key_multibase = format!("{}", &public_key) 239 .strip_prefix("did:key:") 240 .unwrap() 241 .to_string(); 242 243 let did = "did:web:example.com"; 244 let method_id = format!("{did}#atproto"); 245 246 let document = DocumentBuilder::new() 247 .id(did.to_string()) 248 .add_verification_method(VerificationMethod::Multikey { 249 id: method_id.clone(), 250 controller: did.to_string(), 251 public_key_multibase, 252 extra: HashMap::new(), 253 }) 254 .build() 255 .unwrap(); 256 257 let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 258 expected: did.to_string(), 259 document, 260 })); 261 262 let key_data = resolver.resolve(&method_id).await?; 263 assert_eq!(key_data.bytes(), public_key.bytes()); 264 Ok(()) 265 } 266} 267 268/// Resolves a handle to DID using both DNS and HTTP methods. 269/// Returns DID if both methods agree, or error if conflicting. 270#[instrument(skip(http_client, dns_resolver), err)] 271pub async fn resolve_handle<R: DnsResolver + ?Sized>( 272 http_client: &reqwest::Client, 273 dns_resolver: &R, 274 handle: &str, 275) -> Result<String, ResolveError> { 276 let trimmed = { 277 if let Some(value) = handle.trim().strip_prefix("at://") { 278 value 279 } else if let Some(value) = handle.trim().strip_prefix('@') { 280 value 281 } else { 282 handle.trim() 283 } 284 }; 285 286 let (dns_lookup, http_lookup) = tokio::join!( 287 resolve_handle_dns(dns_resolver, trimmed), 288 resolve_handle_http(http_client, trimmed), 289 ); 290 291 let results = vec![dns_lookup, http_lookup] 292 .into_iter() 293 .filter_map(|result| result.ok()) 294 .collect::<Vec<String>>(); 295 if results.is_empty() { 296 return Err(ResolveError::NoDIDsFound); 297 } 298 299 let first = results[0].clone(); 300 if results.iter().all(|result| result == &first) { 301 return Ok(first); 302 } 303 Err(ResolveError::ConflictingDIDsFound) 304} 305 306/// Resolves any subject (handle or DID) to a canonical DID. 307/// Handles all supported identifier formats automatically. 308#[instrument(skip(http_client, dns_resolver), err)] 309pub async fn resolve_subject<R: DnsResolver + ?Sized>( 310 http_client: &reqwest::Client, 311 dns_resolver: &R, 312 subject: &str, 313) -> Result<String, ResolveError> { 314 match parse_input(subject)? { 315 InputType::Handle(handle) => resolve_handle(http_client, dns_resolver, &handle).await, 316 InputType::Plc(did) | InputType::Web(did) => Ok(did), 317 } 318} 319 320/// Core identity resolution components for AT Protocol subjects. 321/// 322/// Contains the networking and configuration components needed to resolve 323/// handles and DIDs to their corresponding DID documents. 324pub struct InnerIdentityResolver { 325 /// DNS resolver for handle-to-DID resolution via TXT records. 326 pub dns_resolver: Arc<dyn DnsResolver>, 327 /// HTTP client for DID document retrieval and well-known endpoint queries. 328 pub http_client: Client, 329 /// Hostname of the PLC directory server for `did:plc` resolution. 330 pub plc_hostname: String, 331} 332 333/// Shared identity resolver for AT Protocol subjects. 334/// 335/// Wraps `InnerIdentityResolver` in an Arc for shared access across threads, 336/// enabling resolution of AT Protocol handles and DIDs to DID documents. 337#[derive(Clone)] 338pub struct SharedIdentityResolver(pub Arc<InnerIdentityResolver>); 339 340impl Deref for SharedIdentityResolver { 341 type Target = InnerIdentityResolver; 342 343 fn deref(&self) -> &Self::Target { 344 &self.0 345 } 346} 347 348#[async_trait::async_trait] 349impl IdentityResolver for SharedIdentityResolver { 350 async fn resolve(&self, subject: &str) -> Result<Document> { 351 self.0.resolve(subject).await 352 } 353} 354 355#[async_trait::async_trait] 356impl IdentityResolver for InnerIdentityResolver { 357 async fn resolve(&self, subject: &str) -> Result<Document> { 358 let resolved_did = resolve_subject(&self.http_client, &*self.dns_resolver, subject).await?; 359 360 match parse_input(&resolved_did) { 361 Ok(InputType::Plc(did)) => plc_query(&self.http_client, &self.plc_hostname, &did) 362 .await 363 .map_err(Into::into), 364 Ok(InputType::Web(did)) => web_query(&self.http_client, &did).await.map_err(Into::into), 365 Ok(InputType::Handle(_)) => Err(ResolveError::SubjectResolvedToHandle.into()), 366 Err(err) => Err(err.into()), 367 } 368 } 369} 370 371impl InnerIdentityResolver { 372 /// Resolves an AT Protocol subject to its DID document. 373 /// 374 /// Takes a handle or DID, resolves it to a canonical DID, then retrieves 375 /// the corresponding DID document from the appropriate source (PLC directory or web). 376 pub async fn resolve(&self, subject: &str) -> Result<Document> { 377 <Self as IdentityResolver>::resolve(self, subject).await 378 } 379}