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}