Skip to main content

atproto_devtool/common/
identity.rs

1//! Identity resolution primitives for ATProto handles, DIDs, services, and multikey parsing.
2//!
3//! This module provides a narrow interface over HTTP and DNS resolution,
4//! allowing callers to swap real network I/O with recorded fixtures in tests.
5
6use async_trait::async_trait;
7use k256::ecdsa::signature::hazmat::PrehashVerifier;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::sync::Arc;
11use thiserror::Error;
12use url::Url;
13
14use crate::common::APP_USER_AGENT;
15
16/// A DID method identifier.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DidMethod {
19    /// `did:plc:` — decentralized identifiers on the PLC directory.
20    Plc,
21    /// `did:web:` — decentralized identifiers over HTTPS.
22    Web,
23    /// An unrecognized DID method.
24    Other,
25}
26
27/// A decentralized identifier (DID), internally stored as a string.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Did(pub String);
30
31impl Did {
32    /// Returns the method component of this DID.
33    pub fn method(&self) -> DidMethod {
34        if self.0.starts_with("did:plc:") {
35            DidMethod::Plc
36        } else if self.0.starts_with("did:web:") {
37            DidMethod::Web
38        } else {
39            DidMethod::Other
40        }
41    }
42}
43
44impl fmt::Display for Did {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(f, "{}", self.0)
47    }
48}
49
50/// A verification method (public key) from a DID document.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct VerificationMethod {
53    /// The identifier for this method, e.g. `"#atproto_labeler"`.
54    pub id: String,
55    /// The cryptographic type, e.g. `"EcdsaSecp256k1VerificationKey2019"`.
56    #[serde(rename = "type")]
57    pub type_: String,
58    /// The controller DID for this method.
59    pub controller: String,
60    /// The public key in multibase format.
61    #[serde(rename = "publicKeyMultibase", skip_serializing_if = "Option::is_none")]
62    pub public_key_multibase: Option<String>,
63}
64
65/// A service endpoint from a DID document.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct Service {
68    /// The identifier for this service, e.g. `"#atproto_labeler"` or `"did:plc:xyz#atproto_labeler"`.
69    pub id: String,
70    /// The service type, e.g. `"AtprotoLabeler"`.
71    #[serde(rename = "type")]
72    pub type_: String,
73    /// The service endpoint URL or value.
74    #[serde(rename = "serviceEndpoint")]
75    pub service_endpoint: String,
76}
77
78/// A minimal DID document with only the fields we need.
79///
80/// Per user-global rules, we use explicit field-level `#[serde(rename)]`
81/// attributes instead of `#[serde(flatten)]`.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct DidDocument {
84    /// The DID identifier.
85    pub id: String,
86    /// Aliases for this DID (e.g., handles).
87    #[serde(rename = "alsoKnownAs", skip_serializing_if = "Option::is_none")]
88    pub also_known_as: Option<Vec<String>>,
89    /// Public keys and other verification methods.
90    #[serde(rename = "verificationMethod", skip_serializing_if = "Option::is_none")]
91    pub verification_method: Option<Vec<VerificationMethod>>,
92    /// Service endpoints provided by this DID.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub service: Option<Vec<Service>>,
95}
96
97/// The parsed DID document along with the raw bytes and source name.
98///
99/// Downstream stages attach the raw bytes to miette diagnostics.
100#[derive(Debug, Clone)]
101pub struct RawDidDocument {
102    /// The parsed DID document.
103    pub parsed: DidDocument,
104    /// The raw bytes returned by the server.
105    pub source_bytes: Arc<[u8]>,
106    /// The URL or source name for diagnostics.
107    pub source_name: String,
108}
109
110/// A public key that may be one of several supported curves.
111#[derive(Debug, Clone)]
112pub enum AnyVerifyingKey {
113    /// secp256k1 verifying key.
114    K256(k256::ecdsa::VerifyingKey),
115    /// P-256 verifying key.
116    P256(p256::ecdsa::VerifyingKey),
117}
118
119impl AnyVerifyingKey {
120    /// Returns the short curve name for this key ("secp256k1" or "P-256").
121    pub fn curve_name(&self) -> &'static str {
122        match self {
123            AnyVerifyingKey::K256(_) => "secp256k1",
124            AnyVerifyingKey::P256(_) => "P-256",
125        }
126    }
127
128    /// Verifies a prehashed signature against this key.
129    ///
130    /// The prehash must be a 32-byte SHA-256 digest.
131    pub fn verify_prehash(
132        &self,
133        prehash: &[u8; 32],
134        sig: &AnySignature,
135    ) -> Result<(), AnySignatureError> {
136        match (self, sig) {
137            (AnyVerifyingKey::K256(key), AnySignature::K256(sig)) => key
138                .verify_prehash(prehash, sig)
139                .map_err(AnySignatureError::K256),
140            (AnyVerifyingKey::P256(key), AnySignature::P256(sig)) => key
141                .verify_prehash(prehash, sig)
142                .map_err(AnySignatureError::P256),
143            _ => Err(AnySignatureError::CurveMismatch),
144        }
145    }
146}
147
148/// A signature that may be one of several supported curves.
149#[derive(Debug, Clone)]
150pub enum AnySignature {
151    /// secp256k1 signature.
152    K256(k256::ecdsa::Signature),
153    /// P-256 signature.
154    P256(p256::ecdsa::Signature),
155}
156
157/// Error from signature verification across multiple curves.
158#[derive(Debug, thiserror::Error)]
159pub enum AnySignatureError {
160    /// secp256k1 signature error.
161    #[error("secp256k1 signature verification failed")]
162    K256(#[source] k256::ecdsa::Error),
163    /// P-256 signature error.
164    #[error("P-256 signature verification failed")]
165    P256(#[source] p256::ecdsa::Error),
166    /// Signature and key use mismatched curves.
167    #[error("Signature and key use mismatched curves")]
168    CurveMismatch,
169}
170
171/// A multikey public key, parsed and ready for signature verification.
172#[derive(Debug, Clone)]
173pub struct ParsedMultikey {
174    /// The verifying key.
175    pub verifying_key: AnyVerifyingKey,
176}
177
178/// Errors during identity resolution.
179#[derive(Debug, Error)]
180pub enum IdentityError {
181    /// Handle was syntactically invalid.
182    #[error("Invalid handle format")]
183    InvalidHandle,
184
185    /// Handle could not be resolved via DNS or HTTPS.
186    #[error("Handle could not be resolved")]
187    HandleUnresolvable {
188        /// DNS lookup error (if any).
189        dns_error: Option<Box<IdentityError>>,
190        /// HTTPS fallback error (if any).
191        http_error: Option<Box<IdentityError>>,
192    },
193
194    /// DNS lookup failed.
195    #[error("DNS lookup failed")]
196    DnsLookupFailed {
197        /// The underlying DNS error.
198        #[source]
199        source: Box<IdentityError>,
200    },
201
202    /// DNS backend resolver error.
203    #[error("DNS backend error")]
204    DnsBackend(#[from] hickory_resolver::ResolveError),
205
206    /// HTTPS fallback for handle resolution failed.
207    #[error("HTTP fallback for handle resolution failed")]
208    HandleHttpFallbackFailed {
209        /// The underlying HTTP error.
210        #[source]
211        source: Box<IdentityError>,
212    },
213
214    /// DID method is not supported.
215    #[error("Unsupported DID method: {method}")]
216    UnsupportedDidMethod { method: String },
217
218    /// DID resolution failed with a non-200 status.
219    #[error("DID resolution failed with status {status}")]
220    DidResolutionFailed {
221        /// The HTTP status code.
222        status: u16,
223        /// The response body.
224        body: String,
225    },
226
227    /// DNS record exists but contains no DID entry.
228    #[error("DNS record for {handle} has no did= entry")]
229    DnsNoDidRecord {
230        /// The handle that was queried.
231        handle: String,
232    },
233
234    /// DID body is invalid or malformed.
235    #[error("Invalid DID body: {body}")]
236    InvalidDidBody {
237        /// The invalid body content.
238        body: String,
239    },
240
241    /// DID document could not be decoded.
242    #[error("DID document decode failed")]
243    DidDocumentDecodeFailed {
244        /// The source name for diagnostics.
245        source_name: String,
246        /// The raw bytes for diagnostics.
247        source_bytes: Arc<[u8]>,
248        /// The JSON parse error.
249        #[source]
250        cause: serde_json::Error,
251    },
252
253    /// Multikey decoding failed.
254    #[error("Multikey decoding failed")]
255    MultikeyDecodeFailed {
256        /// The underlying error.
257        #[source]
258        source: Box<IdentityError>,
259    },
260
261    /// Multibase encoding was not supported.
262    #[error("Unsupported multibase encoding")]
263    UnsupportedMultibase(String),
264
265    /// Curve is not supported.
266    #[error("Unsupported curve")]
267    UnsupportedCurve { codec_prefix: Vec<u8> },
268
269    /// Multikey length was invalid.
270    #[error("Invalid multikey length")]
271    MultikeyLengthInvalid,
272
273    /// HTTP transport error from reqwest.
274    #[error("HTTP transport error")]
275    HttpTransport(#[from] reqwest::Error),
276}
277
278/// Trait for HTTP clients used by identity resolution.
279///
280/// Narrow interface allowing test fixtures to be injected.
281#[async_trait]
282pub trait HttpClient: Send + Sync {
283    /// Fetches the bytes at the given URL, returning status code and body.
284    async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>;
285}
286
287/// Trait for DNS resolvers used by identity resolution.
288///
289/// Narrow interface allowing test fixtures to be injected.
290#[async_trait]
291pub trait DnsResolver: Send + Sync {
292    /// Performs a TXT lookup on the given name.
293    async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError>;
294}
295
296/// Real HTTP client using reqwest.
297pub struct RealHttpClient {
298    inner: reqwest::Client,
299}
300
301impl RealHttpClient {
302    /// Creates a new HTTP client with a conservative 10-second timeout.
303    ///
304    /// Uses rustls for TLS (per Cargo.toml `rustls-tls` feature) rather than native-tls
305    /// to avoid linking against OpenSSL. Sets a User-Agent header for HTTPS requests.
306    pub fn new() -> Result<Self, IdentityError> {
307        let client = reqwest::Client::builder()
308            .use_rustls_tls()
309            .user_agent(APP_USER_AGENT)
310            .timeout(std::time::Duration::from_secs(10))
311            .build()?;
312        Ok(Self { inner: client })
313    }
314
315    /// Creates a new HTTP client from an existing reqwest::Client.
316    ///
317    /// Allows sharing a single client instance across multiple stages.
318    pub fn from_client(client: reqwest::Client) -> Self {
319        Self { inner: client }
320    }
321}
322
323#[async_trait]
324impl HttpClient for RealHttpClient {
325    async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
326        let response = self.inner.get(url.clone()).send().await?;
327        let status = response.status().as_u16();
328        let bytes = response.bytes().await?;
329        Ok((status, bytes.to_vec()))
330    }
331}
332
333/// Real DNS resolver using hickory-resolver.
334pub struct RealDnsResolver {
335    inner: hickory_resolver::TokioResolver,
336}
337
338impl RealDnsResolver {
339    /// Creates a new DNS resolver with system configuration.
340    pub fn new() -> Self {
341        let resolver = hickory_resolver::Resolver::builder_tokio()
342            .expect("failed to build DNS resolver")
343            .build();
344        Self { inner: resolver }
345    }
346}
347
348impl Default for RealDnsResolver {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354#[async_trait]
355impl DnsResolver for RealDnsResolver {
356    async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
357        let lookup = self.inner.txt_lookup(name).await?;
358        lookup
359            .iter()
360            .map(|record| {
361                let text = record
362                    .iter()
363                    .map(|data| {
364                        String::from_utf8(data.to_vec()).unwrap_or_else(|_| {
365                            tracing::debug!(
366                                target = "atproto_devtool::identity",
367                                "dropping non-UTF-8 TXT record data"
368                            );
369                            String::new()
370                        })
371                    })
372                    .collect::<Vec<_>>()
373                    .join("");
374                Ok(text)
375            })
376            .collect()
377    }
378}
379
380/// Resolves an ATProto handle to a DID via DNS or HTTPS fallback.
381///
382/// Attempts DNS lookup on `_atproto.<handle>` for a `did=...` record,
383/// then falls back to HTTPS GET on `https://<handle>/.well-known/atproto-did`.
384pub async fn resolve_handle(
385    handle: &str,
386    http: &dyn HttpClient,
387    dns: &dyn DnsResolver,
388) -> Result<Did, IdentityError> {
389    // Validate handle format: at least one dot, all ASCII, no leading/trailing dot.
390    if handle.is_empty()
391        || handle.starts_with('.')
392        || handle.ends_with('.')
393        || !handle.is_ascii()
394        || !handle.contains('.')
395    {
396        return Err(IdentityError::InvalidHandle);
397    }
398
399    tracing::debug!(
400        target = "atproto_devtool::identity",
401        handle = %handle,
402        "resolving handle"
403    );
404
405    // DNS path: look up _atproto.<handle> for did= records.
406    let dns_name = format!("_atproto.{handle}");
407
408    let dns_error_opt = match dns.txt_lookup(&dns_name).await {
409        Ok(records) => {
410            // Check if any record contains a did= entry.
411            for record in records {
412                let trimmed = record.trim();
413                if let Some(did_str) = trimmed.strip_prefix("did=") {
414                    let did = Did(did_str.to_string());
415                    tracing::debug!(
416                        target = "atproto_devtool::identity",
417                        did = %did,
418                        "resolved handle via DNS"
419                    );
420                    return Ok(did);
421                }
422            }
423            // Records exist but no did= entry found; populate error for fallback.
424            Some(Box::new(IdentityError::DnsNoDidRecord {
425                handle: handle.to_string(),
426            }))
427        }
428        Err(e) => Some(Box::new(e)),
429    };
430
431    // HTTPS fallback: GET https://<handle>/.well-known/atproto-did.
432    let url = format!("https://{handle}/.well-known/atproto-did");
433    let url = url
434        .parse::<Url>()
435        .map_err(|_| IdentityError::InvalidHandle)?;
436
437    let http_error_opt = match http.get_bytes(&url).await {
438        Ok((200, bytes)) => {
439            let did_str = String::from_utf8_lossy(&bytes).trim().to_string();
440            if !did_str.is_empty() && did_str.starts_with("did:") {
441                let did = Did(did_str);
442                tracing::debug!(
443                    target = "atproto_devtool::identity",
444                    did = %did,
445                    "resolved handle via HTTPS"
446                );
447                return Ok(did);
448            } else {
449                Some(Box::new(IdentityError::HandleHttpFallbackFailed {
450                    source: Box::new(IdentityError::InvalidDidBody { body: did_str }),
451                }))
452            }
453        }
454        Ok((status, bytes)) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
455            source: Box::new(IdentityError::DidResolutionFailed {
456                status,
457                body: String::from_utf8_lossy(&bytes).to_string(),
458            }),
459        })),
460        Err(e) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
461            source: Box::new(e),
462        })),
463    };
464
465    // Both paths failed.
466    Err(IdentityError::HandleUnresolvable {
467        dns_error: dns_error_opt,
468        http_error: http_error_opt,
469    })
470}
471
472/// Resolves a DID to a parsed DID document with raw bytes.
473///
474/// Returns both the parsed document and the raw bytes for use in diagnostics.
475pub async fn resolve_did(
476    did: &Did,
477    http: &dyn HttpClient,
478) -> Result<RawDidDocument, IdentityError> {
479    tracing::debug!(
480        target = "atproto_devtool::identity",
481        did = %did,
482        "resolving DID"
483    );
484
485    let (url, source_name) = match did.method() {
486        DidMethod::Plc => {
487            // did:plc identifiers are base32-like and contain only URL-safe characters.
488            // No additional percent-encoding is needed.
489            let did_str = &did.0;
490            let url_str = format!("https://plc.directory/{did_str}");
491            let url = url_str
492                .parse::<Url>()
493                .map_err(|_| IdentityError::DidResolutionFailed {
494                    status: 400,
495                    body: "Invalid DID format".to_string(),
496                })?;
497            (url.clone(), url.to_string())
498        }
499        DidMethod::Web => {
500            // Strip did:web: prefix and URL-decode path segments.
501            let rest = did.0.strip_prefix("did:web:").unwrap_or("");
502            let parts: Vec<&str> = rest.split(':').collect();
503
504            if parts.is_empty() {
505                return Err(IdentityError::DidResolutionFailed {
506                    status: 400,
507                    body: "Invalid did:web format".to_string(),
508                });
509            }
510
511            let host = parts[0];
512            let path_parts = &parts[1..];
513
514            let url_str = if path_parts.is_empty() {
515                format!("https://{host}/.well-known/did.json")
516            } else {
517                let path = path_parts
518                    .iter()
519                    .map(|p| percent_decode_str(p).unwrap_or_default())
520                    .collect::<Vec<_>>()
521                    .join("/");
522                format!("https://{host}/{path}/did.json")
523            };
524
525            let url = url_str
526                .parse::<Url>()
527                .map_err(|_| IdentityError::DidResolutionFailed {
528                    status: 400,
529                    body: "Invalid URL".to_string(),
530                })?;
531            (url.clone(), url.to_string())
532        }
533        DidMethod::Other => {
534            return Err(IdentityError::UnsupportedDidMethod {
535                method: did.0.clone(),
536            });
537        }
538    };
539
540    let (status, bytes) = http.get_bytes(&url).await?;
541
542    if status != 200 {
543        return Err(IdentityError::DidResolutionFailed {
544            status,
545            body: String::from_utf8_lossy(&bytes).to_string(),
546        });
547    }
548
549    tracing::debug!(
550        target = "atproto_devtool::identity",
551        bytes_len = bytes.len(),
552        "fetched DID document"
553    );
554
555    let parsed = serde_json::from_slice::<DidDocument>(&bytes).map_err(|e| {
556        IdentityError::DidDocumentDecodeFailed {
557            source_name: source_name.clone(),
558            source_bytes: Arc::from(bytes.clone()),
559            cause: e,
560        }
561    })?;
562
563    Ok(RawDidDocument {
564        parsed,
565        source_bytes: Arc::from(bytes),
566        source_name,
567    })
568}
569
570/// Finds a service in a DID document by fragment and type.
571///
572/// Matches both `"#fragment"` and `"did:..#fragment"` ID forms.
573pub fn find_service<'a>(
574    doc: &'a DidDocument,
575    id_fragment: &str,
576    expected_type: &str,
577) -> Option<&'a Service> {
578    let services = doc.service.as_ref()?;
579
580    for service in services {
581        // Extract the fragment after '#', matching both forms:
582        // - "#fragment" (short form)
583        // - "did:...#fragment" (full form)
584        let frag = service.id.rsplit_once('#').map(|(_, f)| f);
585        if frag == Some(id_fragment) && service.type_ == expected_type {
586            return Some(service);
587        }
588    }
589
590    None
591}
592
593/// Parses a multikey string into a verifying key.
594///
595/// Accepts either a bare base58btc multibase string (`z…`) or a
596/// `did:key:z…` form. The latter is how PLC audit logs and some DID
597/// documents surface verification methods, so stripping the prefix here
598/// means every caller can stay agnostic to the wire shape.
599pub fn parse_multikey(raw: &str) -> Result<ParsedMultikey, IdentityError> {
600    tracing::debug!(target = "atproto_devtool::identity", "parsing multikey");
601
602    let multibase_str = raw.strip_prefix("did:key:").unwrap_or(raw);
603
604    let (base, bytes) =
605        multibase::decode(multibase_str).map_err(|_| IdentityError::MultikeyDecodeFailed {
606            source: Box::new(IdentityError::UnsupportedMultibase(
607                "failed to decode multibase".to_string(),
608            )),
609        })?;
610
611    // Require base58btc encoding.
612    if base != multibase::Base::Base58Btc {
613        return Err(IdentityError::UnsupportedMultibase(
614            "multikey must use base58btc encoding".to_string(),
615        ));
616    }
617
618    if bytes.len() < 2 {
619        return Err(IdentityError::MultikeyLengthInvalid);
620    }
621
622    // Parse the varint curve identifier (two bytes for both supported curves).
623    let curve_bytes = [bytes[0], bytes[1]];
624    let rest = &bytes[2..];
625
626    match curve_bytes {
627        // secp256k1-pub (0xe7 0x01)
628        [0xe7, 0x01] => {
629            if rest.len() != 33 {
630                return Err(IdentityError::MultikeyLengthInvalid);
631            }
632            let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
633                IdentityError::MultikeyDecodeFailed {
634                    source: Box::new(IdentityError::MultikeyLengthInvalid),
635                }
636            })?;
637            tracing::debug!(
638                target = "atproto_devtool::identity",
639                curve = "secp256k1",
640                "parsed multikey"
641            );
642            Ok(ParsedMultikey {
643                verifying_key: AnyVerifyingKey::K256(key),
644            })
645        }
646        // p256-pub (0x80 0x24)
647        [0x80, 0x24] => {
648            if rest.len() != 33 {
649                return Err(IdentityError::MultikeyLengthInvalid);
650            }
651            let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
652                IdentityError::MultikeyDecodeFailed {
653                    source: Box::new(IdentityError::MultikeyLengthInvalid),
654                }
655            })?;
656            tracing::debug!(
657                target = "atproto_devtool::identity",
658                curve = "p256",
659                "parsed multikey"
660            );
661            Ok(ParsedMultikey {
662                verifying_key: AnyVerifyingKey::P256(key),
663            })
664        }
665        _ => Err(IdentityError::UnsupportedCurve {
666            codec_prefix: curve_bytes.to_vec(),
667        }),
668    }
669}
670
671/// A historic key entry from a PLC audit log for a given verification method fragment.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct PlcHistoricKey {
674    /// The multikey string (the raw key material, not a DID fragment).
675    pub key_id: String,
676    /// The CID of the operation that introduced this key.
677    pub operation_cid: String,
678    /// ISO8601 timestamp from the operation.
679    pub introduced_at: String,
680    /// Whether this key was nullified.
681    pub nullified: bool,
682}
683
684/// Fetches the PLC audit log for a DID and extracts historic keys for a fragment.
685///
686/// Only valid for `did:plc:` DIDs. Panics in debug for other methods; returns
687/// `Err(UnsupportedDidMethod)` in release. Returns keys in chronological order
688/// (oldest-first), matching the PLC API wire order. The caller treats the
689/// result as a set — iteration order does not affect verification correctness.
690///
691/// Transport errors are propagated as `HttpTransport`; decode errors as `DidDocumentDecodeFailed`.
692pub async fn plc_history_for_fragment(
693    did: &Did,
694    fragment: &str,
695    http: &dyn HttpClient,
696) -> Result<Vec<PlcHistoricKey>, IdentityError> {
697    debug_assert!(
698        did.method() == DidMethod::Plc,
699        "plc_history_for_fragment called with non-plc DID: {did}"
700    );
701
702    if did.method() != DidMethod::Plc {
703        return Err(IdentityError::UnsupportedDidMethod {
704            method: format!("{:?}", did.method()),
705        });
706    }
707
708    // Construct the audit log URL: https://plc.directory/{did}/log/audit
709    let audit_url = format!("https://plc.directory/{did}/log/audit");
710    let url = Url::parse(&audit_url).map_err(|_| IdentityError::DidResolutionFailed {
711        status: 400,
712        body: "Invalid PLC audit URL".to_string(),
713    })?;
714
715    let (status, bytes) = http.get_bytes(&url).await?;
716
717    if status != 200 {
718        return Err(IdentityError::DidResolutionFailed {
719            status,
720            body: format!("PLC audit log fetch returned status {status}"),
721        });
722    }
723
724    // Parse the JSON array of operations.
725    let operations: Vec<serde_json::Value> =
726        serde_json::from_slice(&bytes).map_err(|cause| IdentityError::DidDocumentDecodeFailed {
727            source_name: "plc audit log".to_string(),
728            source_bytes: Arc::from(bytes.into_boxed_slice()),
729            cause,
730        })?;
731
732    let mut historic_keys: Vec<PlcHistoricKey> = Vec::new();
733
734    // Walk operations in wire order (oldest-first). Deduplicate by multikey
735    // string: PLC audit logs carry one entry per operation, so a single
736    // persistent key is repeated on every unrelated rotation. Callers want
737    // the set of distinct keys; the dedup keeps the earliest introduction.
738    for op in operations {
739        // Extract the verificationMethods object from operation.
740        let vm = match op
741            .get("operation")
742            .and_then(|o| o.get("verificationMethods"))
743        {
744            Some(vm) => vm,
745            None => continue,
746        };
747
748        // Look for the requested fragment.
749        if let Some(multikey_value) = vm.get(fragment) {
750            let multikey_str = match multikey_value.as_str() {
751                Some(s) => s.to_string(),
752                None => continue,
753            };
754
755            let operation_cid = op
756                .get("cid")
757                .and_then(|c| c.as_str())
758                .unwrap_or("unknown")
759                .to_string();
760
761            // Extract the timestamp from the operation if available.
762            let introduced_at = op
763                .get("operation")
764                .and_then(|o| o.get("createdAt"))
765                .and_then(|c| c.as_str())
766                .unwrap_or("unknown")
767                .to_string();
768
769            let nullified = op
770                .get("nullified")
771                .and_then(|n| n.as_bool())
772                .unwrap_or(false);
773
774            if historic_keys.iter().any(|k| k.key_id == multikey_str) {
775                continue;
776            }
777
778            historic_keys.push(PlcHistoricKey {
779                key_id: multikey_str,
780                operation_cid,
781                introduced_at,
782                nullified,
783            });
784        }
785    }
786
787    Ok(historic_keys)
788}
789
790/// Helper to percent-decode a string.
791fn percent_decode_str(s: &str) -> Result<String, IdentityError> {
792    let decoded = percent_encoding::percent_decode_str(s)
793        .decode_utf8()
794        .map_err(|_| IdentityError::DidResolutionFailed {
795            status: 400,
796            body: "Invalid UTF-8 in percent-encoded path".to_string(),
797        })?;
798    Ok(decoded.to_string())
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use k256::ecdsa::SigningKey as K256SigningKey;
805    use k256::ecdsa::signature::hazmat::PrehashSigner;
806    use p256::ecdsa::SigningKey as P256SigningKey;
807    use std::collections::HashMap;
808
809    /// Response variant for FakeHttpClient.
810    #[derive(Clone)]
811    enum Response {
812        /// HTTP response with status code and body.
813        Http(u16, Vec<u8>),
814        /// Transport error (network-level failure).
815        Transport(String),
816    }
817
818    /// Fake HTTP client for testing, built from a map of URL → response.
819    struct FakeHttpClient {
820        responses: HashMap<String, Response>,
821    }
822
823    #[async_trait]
824    impl HttpClient for FakeHttpClient {
825        async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
826            match self.responses.get(url.as_str()).cloned() {
827                Some(Response::Http(status, body)) => Ok((status, body)),
828                Some(Response::Transport(message)) => {
829                    // Simulate a transport error by returning a synthesized status 0.
830                    // We cannot construct a real reqwest::Error in unit tests, so the
831                    // message is threaded into the body so assertions can inspect it.
832                    Err(IdentityError::DidResolutionFailed {
833                        status: 0,
834                        body: format!("Transport error: {message}"),
835                    })
836                }
837                None => Err(IdentityError::DidResolutionFailed {
838                    status: 404,
839                    body: "Not found".to_string(),
840                }),
841            }
842        }
843    }
844
845    /// Fake DNS resolver for testing, built from a map of name → records.
846    struct FakeDnsResolver {
847        records: HashMap<String, Vec<String>>,
848    }
849
850    #[async_trait]
851    impl DnsResolver for FakeDnsResolver {
852        async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
853            self.records
854                .get(name)
855                .cloned()
856                .ok_or_else(|| IdentityError::DnsLookupFailed {
857                    source: Box::new(IdentityError::InvalidHandle),
858                })
859        }
860    }
861
862    #[tokio::test]
863    async fn resolve_handle_via_dns() {
864        let mut records = HashMap::new();
865        records.insert(
866            "_atproto.alice.example".to_string(),
867            vec!["did=did:plc:abc123".to_string()],
868        );
869        let dns = FakeDnsResolver { records };
870        let http = FakeHttpClient {
871            responses: HashMap::new(),
872        };
873
874        let result = resolve_handle("alice.example", &http, &dns).await;
875
876        assert!(result.is_ok());
877        let did = result.unwrap();
878        assert_eq!(did.0, "did:plc:abc123");
879    }
880
881    #[tokio::test]
882    async fn resolve_handle_via_https_fallback() {
883        let dns = FakeDnsResolver {
884            records: HashMap::new(),
885        };
886        let mut responses = HashMap::new();
887        responses.insert(
888            "https://alice.example/.well-known/atproto-did".to_string(),
889            Response::Http(200, b"did:plc:abc123\n".to_vec()),
890        );
891        let http = FakeHttpClient { responses };
892
893        let result = resolve_handle("alice.example", &http, &dns).await;
894
895        assert!(result.is_ok());
896        let did = result.unwrap();
897        assert_eq!(did.0, "did:plc:abc123");
898    }
899
900    #[tokio::test]
901    async fn resolve_handle_both_paths_fail() {
902        let dns = FakeDnsResolver {
903            records: HashMap::new(),
904        };
905        let http = FakeHttpClient {
906            responses: HashMap::new(),
907        };
908
909        let result = resolve_handle("alice.example", &http, &dns).await;
910
911        assert!(result.is_err());
912        match result.unwrap_err() {
913            IdentityError::HandleUnresolvable {
914                dns_error,
915                http_error,
916            } => {
917                assert!(dns_error.is_some());
918                assert!(http_error.is_some());
919            }
920            _ => panic!("Expected HandleUnresolvable error"),
921        }
922    }
923
924    #[tokio::test]
925    async fn resolve_did_plc_success() {
926        let plc_doc = include_bytes!("../../tests/fixtures/identity/plc_bsky_labeler.json");
927        let mut responses = HashMap::new();
928        responses.insert(
929            "https://plc.directory/did:plc:test-labeler".to_string(),
930            Response::Http(200, plc_doc.to_vec()),
931        );
932        let http = FakeHttpClient { responses };
933
934        let did = Did("did:plc:test-labeler".to_string());
935        let raw_doc = resolve_did(&did, &http).await.expect("resolve_did");
936        assert_eq!(raw_doc.parsed.id, "did:plc:test-labeler");
937        assert!(raw_doc.source_bytes.as_ref() == plc_doc);
938        assert_eq!(
939            raw_doc.source_name,
940            "https://plc.directory/did:plc:test-labeler"
941        );
942
943        // Verify both services are present.
944        let services = raw_doc.parsed.service.as_ref().expect("services");
945        assert!(
946            services.iter().any(|s| s.type_ == "AtprotoLabeler"),
947            "fixture must contain a labeler service"
948        );
949        assert!(
950            services
951                .iter()
952                .any(|s| s.type_ == "AtprotoPersonalDataServer"),
953            "fixture must contain a PDS service"
954        );
955
956        // Verify both verification methods are present.
957        let vms = raw_doc
958            .parsed
959            .verification_method
960            .as_ref()
961            .expect("verificationMethod");
962        assert!(
963            vms.iter().any(|vm| vm.id == "#atproto"),
964            "fixture must contain a repo signing key"
965        );
966        assert!(
967            vms.iter().any(|vm| vm.id == "#atproto_label"),
968            "fixture must contain a label signing key"
969        );
970    }
971
972    #[tokio::test]
973    async fn resolve_did_web_success() {
974        let web_doc = include_bytes!("../../tests/fixtures/identity/web_example.json");
975        let mut responses = HashMap::new();
976        responses.insert(
977            "https://example.com/.well-known/did.json".to_string(),
978            Response::Http(200, web_doc.to_vec()),
979        );
980        let http = FakeHttpClient { responses };
981
982        let did = Did("did:web:example.com".to_string());
983        let result = resolve_did(&did, &http).await;
984
985        assert!(result.is_ok());
986        let raw_doc = result.unwrap();
987        assert_eq!(raw_doc.parsed.id, "did:web:example.com");
988        assert_eq!(
989            raw_doc.source_name,
990            "https://example.com/.well-known/did.json"
991        );
992    }
993
994    #[tokio::test]
995    async fn resolve_did_decode_failure_preserves_bytes() {
996        let bad_json = b"not valid json";
997        let mut responses = HashMap::new();
998        responses.insert(
999            "https://plc.directory/did:plc:bad".to_string(),
1000            Response::Http(200, bad_json.to_vec()),
1001        );
1002        let http = FakeHttpClient { responses };
1003
1004        let did = Did("did:plc:bad".to_string());
1005        let result = resolve_did(&did, &http).await;
1006
1007        assert!(result.is_err());
1008        match result.unwrap_err() {
1009            IdentityError::DidDocumentDecodeFailed {
1010                source_name: _,
1011                source_bytes,
1012                cause: _,
1013            } => {
1014                assert_eq!(source_bytes.as_ref(), bad_json);
1015            }
1016            _ => panic!("Expected DidDocumentDecodeFailed error"),
1017        }
1018    }
1019
1020    #[test]
1021    fn find_service_matches_both_id_forms() {
1022        let doc = DidDocument {
1023            id: "did:plc:abc".to_string(),
1024            also_known_as: None,
1025            verification_method: None,
1026            service: Some(vec![
1027                Service {
1028                    id: "did:plc:abc#atproto_labeler".to_string(),
1029                    type_: "AtprotoLabeler".to_string(),
1030                    service_endpoint: "https://example.com/labeler".to_string(),
1031                },
1032                Service {
1033                    id: "#atproto_pds".to_string(),
1034                    type_: "AtprotoPersonalDataServer".to_string(),
1035                    service_endpoint: "https://example.com/pds".to_string(),
1036                },
1037                // Service with a name that contains the search fragment as a substring.
1038                Service {
1039                    id: "#xatproto_labeler".to_string(),
1040                    type_: "OtherType".to_string(),
1041                    service_endpoint: "https://example.com/other".to_string(),
1042                },
1043            ]),
1044        };
1045
1046        let labeler = find_service(&doc, "atproto_labeler", "AtprotoLabeler");
1047        assert!(labeler.is_some());
1048        let labeler = labeler.unwrap();
1049        assert_eq!(labeler.id, "did:plc:abc#atproto_labeler");
1050
1051        let pds = find_service(&doc, "atproto_pds", "AtprotoPersonalDataServer");
1052        assert!(pds.is_some());
1053        let pds = pds.unwrap();
1054        assert_eq!(pds.id, "#atproto_pds");
1055
1056        // Ensure false-positive on substring is not matched.
1057        let wrong = find_service(&doc, "atproto_labeler", "OtherType");
1058        assert!(wrong.is_none());
1059    }
1060
1061    #[test]
1062    fn find_service_type_mismatch_returns_none() {
1063        let doc = DidDocument {
1064            id: "did:plc:abc".to_string(),
1065            also_known_as: None,
1066            verification_method: None,
1067            service: Some(vec![Service {
1068                id: "#atproto_labeler".to_string(),
1069                type_: "AtprotoLabeler".to_string(),
1070                service_endpoint: "https://example.com/labeler".to_string(),
1071            }]),
1072        };
1073
1074        let result = find_service(&doc, "atproto_labeler", "WrongType");
1075        assert!(result.is_none());
1076    }
1077
1078    #[test]
1079    fn parse_multikey_k256() {
1080        // Generated from an ephemeral k256 keypair.
1081        // These keys are not production keys; they are synthetic fixtures for testing.
1082        let multikey = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
1083
1084        let result = parse_multikey(multikey);
1085        assert!(result.is_ok());
1086
1087        let parsed = result.unwrap();
1088
1089        // Verify the key can be extracted and its bytes match the expected value.
1090        match &parsed.verifying_key {
1091            AnyVerifyingKey::K256(key) => {
1092                let sec1_bytes = key.to_sec1_bytes();
1093                assert_eq!(sec1_bytes.len(), 33); // Compressed form is 33 bytes.
1094                // Expected bytes derived from the multikey fixture.
1095                // zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9 decodes to:
1096                // [0xe7, 0x01] (k256 prefix) + 33-byte SEC1 point.
1097                let expected_hex =
1098                    "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1099                let actual_hex: String = sec1_bytes.iter().map(|b| format!("{b:02x}")).collect();
1100                assert_eq!(actual_hex, expected_hex);
1101            }
1102            _ => panic!("Expected K256 verifying key"),
1103        }
1104    }
1105
1106    #[test]
1107    fn parse_multikey_p256() {
1108        // Generated from an ephemeral p256 keypair.
1109        // These keys are not production keys; they are synthetic fixtures for testing.
1110        let multikey = include_str!("../../tests/fixtures/identity/multikey_p256.txt").trim();
1111
1112        let result = parse_multikey(multikey);
1113        assert!(result.is_ok());
1114
1115        let parsed = result.unwrap();
1116
1117        // Verify the key can be extracted and is the correct type.
1118        match &parsed.verifying_key {
1119            AnyVerifyingKey::P256(key) => {
1120                // Force compressed form for consistent testing.
1121                let sec1_bytes = key.to_encoded_point(true).as_bytes().to_vec();
1122                assert_eq!(sec1_bytes.len(), 33);
1123                // Expected bytes derived from the multikey fixture.
1124                let expected_hex =
1125                    "026b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296";
1126                let actual_hex: String = sec1_bytes.iter().map(|b| format!("{b:02x}")).collect();
1127                assert_eq!(actual_hex, expected_hex);
1128            }
1129            _ => panic!("Expected P256 verifying key"),
1130        }
1131    }
1132
1133    #[test]
1134    fn parse_multikey_unsupported_curve() {
1135        // Build a multikey with an unsupported curve prefix (0x01 0x00).
1136        let mut unsupported_bytes = vec![0x01, 0x00];
1137        unsupported_bytes.extend_from_slice(&[0; 33]); // Fake 33-byte key.
1138        // multibase::encode already returns the z-prefixed string for Base58Btc.
1139        let multikey = multibase::encode(multibase::Base::Base58Btc, unsupported_bytes);
1140
1141        let result = parse_multikey(&multikey);
1142        assert!(result.is_err());
1143
1144        match result.unwrap_err() {
1145            IdentityError::UnsupportedCurve { codec_prefix: _ } => {}
1146            _ => panic!("Expected UnsupportedCurve error"),
1147        }
1148    }
1149
1150    #[test]
1151    fn parse_multikey_not_base58btc() {
1152        // Use base16 encoding instead of base58btc.
1153        let mut key_bytes = vec![0xe7, 0x01];
1154        key_bytes.extend_from_slice(&[0; 33]);
1155        // Manually create base16 multibase string (f prefix).
1156        let hex_str: String = key_bytes.iter().map(|b| format!("{b:02x}")).collect();
1157        let multikey = format!("f{hex_str}");
1158
1159        let result = parse_multikey(&multikey);
1160        assert!(result.is_err());
1161
1162        match result.unwrap_err() {
1163            IdentityError::UnsupportedMultibase(_) => {}
1164            _ => panic!("Expected UnsupportedMultibase error"),
1165        }
1166    }
1167
1168    #[test]
1169    fn parse_multikey_accepts_did_key_prefix() {
1170        // The bare multikey parses, and prepending "did:key:" must be equivalent.
1171        // PLC audit logs store verificationMethods values in the did:key form,
1172        // so this path is load-bearing for the crypto stage's history fallback.
1173        let bare = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
1174        let did_key = format!("did:key:{bare}");
1175
1176        let from_bare = parse_multikey(bare).expect("bare multikey should parse");
1177        let from_did_key = parse_multikey(&did_key).expect("did:key multikey should parse");
1178
1179        assert!(matches!(from_bare.verifying_key, AnyVerifyingKey::K256(_)));
1180        assert!(matches!(
1181            from_did_key.verifying_key,
1182            AnyVerifyingKey::K256(_)
1183        ));
1184    }
1185
1186    #[test]
1187    fn parse_multikey_wrong_length() {
1188        // Build a multikey with a 10-byte body instead of 33.
1189        let mut wrong_len_bytes = vec![0x80, 0x24]; // p256 prefix
1190        wrong_len_bytes.extend_from_slice(&[0; 10]); // Only 10 bytes instead of 33
1191
1192        // multibase::encode already returns the z-prefixed string for Base58Btc.
1193        let multikey = multibase::encode(multibase::Base::Base58Btc, &wrong_len_bytes);
1194
1195        let result = parse_multikey(&multikey);
1196        assert!(result.is_err());
1197
1198        // Must strictly assert MultikeyLengthInvalid.
1199        match result.unwrap_err() {
1200            IdentityError::MultikeyLengthInvalid => {
1201                // Correct error variant.
1202            }
1203            e => panic!("Expected MultikeyLengthInvalid, got {e:?}"),
1204        }
1205    }
1206
1207    #[test]
1208    fn verify_prehash_k256_valid() {
1209        // Create an ephemeral k256 keypair for testing.
1210        let signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1211        let verifying_key = signing_key.verifying_key();
1212
1213        // Create a test prehash (32 bytes).
1214        let prehash = *b"01234567890123456789012345678901";
1215
1216        // Sign the prehash.
1217        let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
1218
1219        // Wrap in our generic types.
1220        let any_key = AnyVerifyingKey::K256(*verifying_key);
1221        let any_sig = AnySignature::K256(signature);
1222
1223        // Verify should succeed.
1224        assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
1225    }
1226
1227    #[test]
1228    fn verify_prehash_p256_valid() {
1229        // Create an ephemeral p256 keypair for testing.
1230        let signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1231        let verifying_key = signing_key.verifying_key();
1232
1233        // Create a test prehash (32 bytes).
1234        let prehash = *b"01234567890123456789012345678901";
1235
1236        // Sign the prehash.
1237        let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
1238
1239        // Wrap in our generic types.
1240        let any_key = AnyVerifyingKey::P256(*verifying_key);
1241        let any_sig = AnySignature::P256(signature);
1242
1243        // Verify should succeed.
1244        assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
1245    }
1246
1247    #[test]
1248    fn verify_prehash_curve_mismatch() {
1249        // Test k256 key with p256 signature (should fail).
1250        let k256_signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1251        let k256_key = AnyVerifyingKey::K256(*k256_signing_key.verifying_key());
1252
1253        let p256_signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1254        let prehash = *b"01234567890123456789012345678901";
1255        let p256_sig = p256_signing_key
1256            .sign_prehash(&prehash)
1257            .expect("signing failed");
1258        let p256_any_sig = AnySignature::P256(p256_sig);
1259
1260        // Verify should fail due to curve mismatch.
1261        assert!(k256_key.verify_prehash(&prehash, &p256_any_sig).is_err());
1262
1263        // Test p256 key with k256 signature (symmetric case).
1264        let p256_signing_key_2 =
1265            P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1266        let p256_key = AnyVerifyingKey::P256(*p256_signing_key_2.verifying_key());
1267
1268        let k256_signing_key_2 =
1269            K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1270        let k256_sig = k256_signing_key_2
1271            .sign_prehash(&prehash)
1272            .expect("signing failed");
1273        let k256_any_sig = AnySignature::K256(k256_sig);
1274
1275        // Verify should fail due to curve mismatch.
1276        assert!(p256_key.verify_prehash(&prehash, &k256_any_sig).is_err());
1277    }
1278
1279    #[tokio::test]
1280    async fn plc_history_parses_rotation_fixture() {
1281        // Load the fixture with one key rotation.
1282        let fixture_bytes =
1283            include_bytes!("../../tests/fixtures/identity/plc_audit_log_with_rotation.json");
1284        let mut responses = HashMap::new();
1285        responses.insert(
1286            "https://plc.directory/did:plc:test/log/audit".to_string(),
1287            Response::Http(200, fixture_bytes.to_vec()),
1288        );
1289        let http = FakeHttpClient { responses };
1290
1291        let did = Did("did:plc:test".to_string());
1292        let result = plc_history_for_fragment(&did, "atproto_label", &http)
1293            .await
1294            .expect("plc_history should succeed");
1295
1296        // Expect two distinct keys from the fixture, in chronological order.
1297        assert_eq!(result.len(), 2);
1298        assert_eq!(
1299            result[0].key_id,
1300            "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y"
1301        );
1302        assert!(!result[0].nullified);
1303        assert_eq!(
1304            result[1].key_id,
1305            "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"
1306        );
1307        assert!(!result[1].nullified);
1308    }
1309
1310    #[tokio::test]
1311    async fn plc_history_dedupes_repeated_key() {
1312        // Craft an audit log where the same atproto_label multikey appears
1313        // across several operations (e.g. because only unrelated signing
1314        // keys rotated). The caller wants the set of distinct historic keys.
1315        let key = "did:key:zQ3shw6eSipD1cnrmmokVWvKCuE6Yc9j2jAjWJ9nWpuF4yQKV";
1316        let log = serde_json::json!([
1317            {"cid": "op3", "operation": {"verificationMethods": {"atproto_label": key}}},
1318            {"cid": "op2", "operation": {"verificationMethods": {"atproto_label": key}}},
1319            {"cid": "op1", "operation": {"verificationMethods": {"atproto_label": key}}},
1320        ]);
1321        let mut responses = HashMap::new();
1322        responses.insert(
1323            "https://plc.directory/did:plc:dedupe/log/audit".to_string(),
1324            Response::Http(200, serde_json::to_vec(&log).unwrap()),
1325        );
1326        let http = FakeHttpClient { responses };
1327
1328        let did = Did("did:plc:dedupe".to_string());
1329        let result = plc_history_for_fragment(&did, "atproto_label", &http)
1330            .await
1331            .expect("plc_history should succeed");
1332
1333        assert_eq!(result.len(), 1);
1334        assert_eq!(result[0].key_id, key);
1335        // Newest entry wins on dedupe.
1336        assert_eq!(result[0].operation_cid, "op3");
1337    }
1338
1339    #[tokio::test]
1340    #[should_panic(expected = "plc_history_for_fragment called with non-plc DID")]
1341    async fn plc_history_unsupported_method_errors() {
1342        // did:web should panic in debug (due to debug_assert).
1343        // In release, it would return Err(UnsupportedDidMethod).
1344        let mut responses = HashMap::new();
1345        responses.insert(
1346            "https://plc.directory/did:web:example.com/log/audit".to_string(),
1347            Response::Http(200, b"[]".to_vec()),
1348        );
1349        let http = FakeHttpClient { responses };
1350
1351        let did = Did("did:web:example.com".to_string());
1352        let _result = plc_history_for_fragment(&did, "atproto_label", &http).await;
1353    }
1354
1355    #[tokio::test]
1356    async fn plc_history_transport_error_propagates() {
1357        // FakeHttpClient returning a transport error (represented as status 0).
1358        let mut responses = HashMap::new();
1359        responses.insert(
1360            "https://plc.directory/did:plc:test/log/audit".to_string(),
1361            Response::Transport("connection refused".to_string()),
1362        );
1363        let http = FakeHttpClient { responses };
1364
1365        let did = Did("did:plc:test".to_string());
1366        let result = plc_history_for_fragment(&did, "atproto_label", &http).await;
1367
1368        assert!(result.is_err());
1369        // The error should be DidResolutionFailed with status 0 (transport error).
1370        match result.unwrap_err() {
1371            IdentityError::DidResolutionFailed { status, body } => {
1372                assert_eq!(status, 0);
1373                assert_eq!(body, "Transport error: connection refused");
1374            }
1375            e => panic!("Expected DidResolutionFailed with status 0, got {e:?}"),
1376        }
1377    }
1378}