A better Rust ATProto crate
at lifetimes 17 kB view raw
1//! Identity resolution: handle → DID and DID → document, with smart fallbacks. 2//! 3//! Fallback order (default): 4//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC 5//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured). 6//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured), 7//! then Slingshot mini‑doc (partial) if configured. 8//! 9//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 10//! and optionally validate the document `id` against the requested DID. 11 12use std::collections::BTreeMap; 13use std::str::FromStr; 14 15use bon::Builder; 16use bytes::Bytes; 17use http::StatusCode; 18use jacquard_common::error::TransportError; 19use jacquard_common::types::did::Did; 20use jacquard_common::types::did_doc::{DidDocument, Service}; 21use jacquard_common::types::ident::AtIdentifier; 22use jacquard_common::types::string::{AtprotoStr, Handle}; 23use jacquard_common::types::uri::Uri; 24use jacquard_common::types::value::{AtDataError, Data}; 25use jacquard_common::{CowStr, IntoStatic}; 26use miette::Diagnostic; 27use thiserror::Error; 28use url::Url; 29 30/// Errors that can occur during identity resolution. 31/// 32/// Note: when validating a fetched DID document against a requested DID, a 33/// `DocIdMismatch` error is returned that includes the owned document so callers 34/// can inspect it and decide how to proceed. 35#[derive(Debug, Error, Diagnostic)] 36#[allow(missing_docs)] 37pub enum IdentityError { 38 #[error("unsupported DID method: {0}")] 39 #[diagnostic( 40 code(jacquard_identity::unsupported_did_method), 41 help("supported DID methods: did:web, did:plc") 42 )] 43 UnsupportedDidMethod(String), 44 #[error("invalid well-known atproto-did content")] 45 #[diagnostic( 46 code(jacquard_identity::invalid_well_known), 47 help("expected first non-empty line to be a DID") 48 )] 49 InvalidWellKnown, 50 #[error("missing PDS endpoint in DID document")] 51 #[diagnostic(code(jacquard_identity::missing_pds_endpoint))] 52 MissingPdsEndpoint, 53 #[error("HTTP error: {0}")] 54 #[diagnostic( 55 code(jacquard_identity::http), 56 help("check network connectivity and TLS configuration") 57 )] 58 Http(#[from] TransportError), 59 #[error("HTTP status {0}")] 60 #[diagnostic( 61 code(jacquard_identity::http_status), 62 help("verify well-known paths or PDS XRPC endpoints") 63 )] 64 HttpStatus(StatusCode), 65 #[error("XRPC error: {0}")] 66 #[diagnostic( 67 code(jacquard_identity::xrpc), 68 help("enable PDS fallback or public resolver if needed") 69 )] 70 Xrpc(String), 71 #[error("URL parse error: {0}")] 72 #[diagnostic(code(jacquard_identity::url))] 73 Url(#[from] url::ParseError), 74 #[error("DNS error: {0}")] 75 #[cfg(feature = "dns")] 76 #[diagnostic(code(jacquard_identity::dns))] 77 Dns(#[from] hickory_resolver::error::ResolveError), 78 #[error("serialize/deserialize error: {0}")] 79 #[diagnostic(code(jacquard_identity::serde))] 80 Serde(#[from] serde_json::Error), 81 #[error("invalid DID document: {0}")] 82 #[diagnostic( 83 code(jacquard_identity::invalid_doc), 84 help("validate keys and services; ensure AtprotoPersonalDataServer service exists") 85 )] 86 InvalidDoc(String), 87 #[error(transparent)] 88 #[diagnostic(code(jacquard_identity::data))] 89 Data(#[from] AtDataError), 90 /// DID document id did not match requested DID; includes the fetched document 91 #[error("DID doc id mismatch")] 92 #[diagnostic( 93 code(jacquard_identity::doc_id_mismatch), 94 help("document id differs from requested DID; do not trust this document") 95 )] 96 DocIdMismatch { 97 expected: Did<'static>, 98 doc: DidDocument<'static>, 99 }, 100} 101 102/// Source to fetch PLC (did:plc) documents from. 103/// 104/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). 105/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as 106/// `com.atproto.identity.resolveHandle` and a "mini-doc" 107/// endpoint (`com.bad-example.identity.resolveMiniDoc`). 108#[derive(Debug, Clone, PartialEq, Eq)] 109pub enum PlcSource { 110 /// Use the public PLC directory 111 PlcDirectory { 112 /// Base URL for the PLC directory 113 base: Url, 114 }, 115 /// Use the slingshot mini-docs service 116 Slingshot { 117 /// Base URL for the Slingshot service 118 base: Url, 119 }, 120} 121 122impl Default for PlcSource { 123 fn default() -> Self { 124 Self::PlcDirectory { 125 base: Url::parse("https://plc.directory/").expect("valid url"), 126 } 127 } 128} 129 130impl PlcSource { 131 /// Default Slingshot source (`https://slingshot.microcosm.blue`) 132 pub fn slingshot_default() -> Self { 133 PlcSource::Slingshot { 134 base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), 135 } 136 } 137} 138 139/// DID Document fetch response for borrowed/owned parsing. 140/// 141/// Carries the raw response bytes and the HTTP status, plus the requested DID 142/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer 143/// or `parse_validated()` to also enforce that the doc `id` matches the 144/// requested DID (returns a `DocIdMismatch` containing the fetched doc on 145/// mismatch). Use `into_owned()` to parse into an owned document. 146#[derive(Clone)] 147pub struct DidDocResponse { 148 #[allow(missing_docs)] 149 pub buffer: Bytes, 150 #[allow(missing_docs)] 151 pub status: StatusCode, 152 /// Optional DID we intended to resolve; used for validation helpers 153 pub requested: Option<Did<'static>>, 154} 155 156impl DidDocResponse { 157 /// Parse as borrowed DidDocument<'_> 158 pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 159 if self.status.is_success() { 160 if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 161 Ok(doc) 162 } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) { 163 Ok(DidDocument { 164 id: mini_doc.did, 165 also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 166 verification_method: None, 167 service: Some(vec![Service { 168 id: CowStr::new_static("#atproto_pds"), 169 r#type: CowStr::new_static("AtprotoPersonalDataServer"), 170 service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 171 Url::from_str(&mini_doc.pds).unwrap(), 172 )))), 173 extra_data: BTreeMap::new(), 174 }]), 175 extra_data: BTreeMap::new(), 176 }) 177 } else { 178 Err(IdentityError::MissingPdsEndpoint) 179 } 180 } else { 181 Err(IdentityError::HttpStatus(self.status)) 182 } 183 } 184 185 /// Parse and validate that the DID in the document matches the requested DID if present. 186 /// 187 /// On mismatch, returns an error that contains the owned document for inspection. 188 pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 189 let doc = self.parse()?; 190 if let Some(expected) = &self.requested { 191 if doc.id.as_str() != expected.as_str() { 192 return Err(IdentityError::DocIdMismatch { 193 expected: expected.clone(), 194 doc: doc.clone().into_static(), 195 }); 196 } 197 } 198 Ok(doc) 199 } 200 201 /// Parse as owned DidDocument<'static> 202 pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 203 if self.status.is_success() { 204 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 205 Ok(doc.into_static()) 206 } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) { 207 Ok(DidDocument { 208 id: mini_doc.did, 209 also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 210 verification_method: None, 211 service: Some(vec![Service { 212 id: CowStr::new_static("#atproto_pds"), 213 r#type: CowStr::new_static("AtprotoPersonalDataServer"), 214 service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 215 Url::from_str(&mini_doc.pds).unwrap(), 216 )))), 217 extra_data: BTreeMap::new(), 218 }]), 219 extra_data: BTreeMap::new(), 220 } 221 .into_static()) 222 } else { 223 Err(IdentityError::MissingPdsEndpoint) 224 } 225 } else { 226 Err(IdentityError::HttpStatus(self.status)) 227 } 228 } 229} 230 231/// Slingshot mini-doc data (subset of DID doc info) 232#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 233#[serde(rename_all = "camelCase")] 234#[allow(missing_docs)] 235pub struct MiniDoc<'a> { 236 #[serde(borrow)] 237 pub did: Did<'a>, 238 #[serde(borrow)] 239 pub handle: Handle<'a>, 240 #[serde(borrow)] 241 pub pds: CowStr<'a>, 242 #[serde(borrow, rename = "signingKey", alias = "signing_key")] 243 pub signing_key: CowStr<'a>, 244} 245 246/// Handle → DID fallback step. 247#[derive(Debug, Clone, Copy, PartialEq, Eq)] 248pub enum HandleStep { 249 /// DNS TXT _atproto.\<handle\> 250 DnsTxt, 251 /// HTTPS GET https://\<handle\>/.well-known/atproto-did 252 HttpsWellKnown, 253 /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 254 PdsResolveHandle, 255} 256 257/// DID → Doc fallback step. 258#[derive(Debug, Clone, Copy, PartialEq, Eq)] 259pub enum DidStep { 260 /// For did:web: fetch from the well-known location 261 DidWebHttps, 262 /// For did:plc: fetch from PLC source 263 PlcHttp, 264 /// If a PDS base is known, ask it for the DID doc 265 PdsResolveDid, 266} 267 268/// Configurable resolver options. 269/// 270/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 271/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless 272/// XRPC over reqwest; authentication can be layered as needed). 273/// - `handle_order`/`did_order`: ordered strategies for resolution. 274/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 275/// returning `DocIdMismatch` with the fetched document on mismatch. 276/// - `public_fallback_for_handle`: if true (default), attempt 277/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 278/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC 279/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 280#[derive(Debug, Clone, Builder)] 281#[builder(start_fn = new)] 282pub struct ResolverOptions { 283 /// PLC data source (directory or slingshot) 284 pub plc_source: PlcSource, 285 /// Optional PDS base to use for fallbacks 286 pub pds_fallback: Option<Url>, 287 /// Order of attempts for handle → DID resolution 288 pub handle_order: Vec<HandleStep>, 289 /// Order of attempts for DID → Doc resolution 290 pub did_order: Vec<DidStep>, 291 /// Validate that fetched DID document id matches the requested DID 292 pub validate_doc_id: bool, 293 /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 294 pub public_fallback_for_handle: bool, 295} 296 297impl Default for ResolverOptions { 298 fn default() -> Self { 299 // By default, prefer DNS then HTTPS for handles, then PDS fallback 300 // For DID documents, prefer method-native sources, then PDS fallback 301 Self::new() 302 .plc_source(PlcSource::default()) 303 .handle_order(vec![ 304 HandleStep::DnsTxt, 305 HandleStep::HttpsWellKnown, 306 HandleStep::PdsResolveHandle, 307 ]) 308 .did_order(vec![ 309 DidStep::DidWebHttps, 310 DidStep::PlcHttp, 311 DidStep::PdsResolveDid, 312 ]) 313 .validate_doc_id(true) 314 .public_fallback_for_handle(true) 315 .build() 316 } 317} 318 319/// Trait for identity resolution, for pluggable implementations. 320/// 321/// The provided `DefaultResolver` supports: 322/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 323/// - HTTPS well-known for handles and `did:web` 324/// - PLC directory or Slingshot for `did:plc` 325/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 326/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 327 328pub trait IdentityResolver { 329 /// Access options for validation decisions in default methods 330 fn options(&self) -> &ResolverOptions; 331 332 /// Resolve handle 333 fn resolve_handle( 334 &self, 335 handle: &Handle<'_>, 336 ) -> impl Future<Output = Result<Did<'static>, IdentityError>>; 337 338 /// Resolve DID document 339 fn resolve_did_doc( 340 &self, 341 did: &Did<'_>, 342 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>>; 343 344 /// Resolve DID doc from an identifier 345 fn resolve_ident( 346 &self, 347 actor: &AtIdentifier<'_>, 348 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> { 349 async move { 350 match actor { 351 AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, 352 AtIdentifier::Handle(handle) => { 353 let did = self.resolve_handle(&handle).await?; 354 self.resolve_did_doc(&did).await 355 } 356 } 357 } 358 } 359 360 /// Resolve DID doc from an identifier 361 fn resolve_ident_owned( 362 &self, 363 actor: &AtIdentifier<'_>, 364 ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 365 async move { 366 match actor { 367 AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, 368 AtIdentifier::Handle(handle) => { 369 let did = self.resolve_handle(&handle).await?; 370 self.resolve_did_doc_owned(&did).await 371 } 372 } 373 } 374 } 375 376 /// Resolve the DID document and return an owned version 377 fn resolve_did_doc_owned( 378 &self, 379 did: &Did<'_>, 380 ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 381 async { self.resolve_did_doc(did).await?.into_owned() } 382 } 383 /// Return the PDS url for a DID 384 fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> { 385 async { 386 let resp = self.resolve_did_doc(did).await?; 387 let doc = resp.parse()?; 388 // Default-on doc id equality check 389 if self.options().validate_doc_id { 390 if doc.id.as_str() != did.as_str() { 391 return Err(IdentityError::DocIdMismatch { 392 expected: did.clone().into_static(), 393 doc: doc.clone().into_static(), 394 }); 395 } 396 } 397 doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 398 } 399 } 400 /// Return the DIS and PDS url for a handle 401 fn pds_for_handle( 402 &self, 403 handle: &Handle<'_>, 404 ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> { 405 async { 406 let did = self.resolve_handle(handle).await?; 407 let pds = self.pds_for_did(&did).await?; 408 Ok((did, pds)) 409 } 410 } 411} 412 413impl<T: IdentityResolver> IdentityResolver for std::sync::Arc<T> { 414 fn options(&self) -> &ResolverOptions { 415 self.as_ref().options() 416 } 417 418 /// Resolve handle 419 async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 420 self.as_ref().resolve_handle(handle).await 421 } 422 423 /// Resolve DID document 424 async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 425 self.as_ref().resolve_did_doc(did).await 426 } 427} 428 429#[cfg(test)] 430mod tests { 431 use super::*; 432 433 #[test] 434 fn parse_validated_ok() { 435 let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 436 let requested = Did::new_owned("did:plc:alice").unwrap(); 437 let resp = DidDocResponse { 438 buffer: buf, 439 status: StatusCode::OK, 440 requested: Some(requested), 441 }; 442 let _doc = resp.parse_validated().expect("valid"); 443 } 444 445 #[test] 446 fn parse_validated_mismatch() { 447 let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 448 let requested = Did::new_owned("did:plc:alice").unwrap(); 449 let resp = DidDocResponse { 450 buffer: buf, 451 status: StatusCode::OK, 452 requested: Some(requested), 453 }; 454 match resp.parse_validated() { 455 Err(IdentityError::DocIdMismatch { expected, doc }) => { 456 assert_eq!(expected.as_str(), "did:plc:alice"); 457 assert_eq!(doc.id.as_str(), "did:plc:bob"); 458 } 459 other => panic!("unexpected result: {:?}", other), 460 } 461 } 462}