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 private signing key that may be one of several supported curves.
149///
150/// Mirrors `AnyVerifyingKey` for the signing side. Signatures produced by
151/// `sign` are always low-s normalized to match the atproto convention
152/// already established by `AnySignature`.
153#[derive(Debug, Clone)]
154pub enum AnySigningKey {
155    /// secp256k1 signing key.
156    K256(k256::ecdsa::SigningKey),
157    /// P-256 signing key.
158    P256(p256::ecdsa::SigningKey),
159}
160
161impl AnySigningKey {
162    /// Returns the corresponding verifying key.
163    pub fn verifying_key(&self) -> AnyVerifyingKey {
164        match self {
165            AnySigningKey::K256(k) => AnyVerifyingKey::K256(*k.verifying_key()),
166            AnySigningKey::P256(k) => AnyVerifyingKey::P256(*k.verifying_key()),
167        }
168    }
169
170    /// Returns the JWT `alg` header identifier for this key's curve
171    /// ("ES256K" for secp256k1, "ES256" for P-256).
172    pub fn jwt_alg(&self) -> &'static str {
173        match self {
174            AnySigningKey::K256(_) => "ES256K",
175            AnySigningKey::P256(_) => "ES256",
176        }
177    }
178
179    /// Signs the SHA-256 prehash of `msg` and returns the signature in
180    /// low-s normalized form.
181    ///
182    /// The returned `AnySignature` is guaranteed to satisfy
183    /// `AnyVerifyingKey::verify_prehash` against the corresponding
184    /// verifying key when given the same prehash bytes.
185    pub fn sign(&self, msg: &[u8]) -> AnySignature {
186        use sha2::{Digest, Sha256};
187        let prehash: [u8; 32] = Sha256::digest(msg).into();
188        self.sign_prehash(&prehash)
189    }
190
191    /// Signs a precomputed 32-byte SHA-256 prehash directly.
192    pub fn sign_prehash(&self, prehash: &[u8; 32]) -> AnySignature {
193        use k256::ecdsa::signature::hazmat::PrehashSigner as K256PrehashSigner;
194        use p256::ecdsa::signature::hazmat::PrehashSigner as P256PrehashSigner;
195        match self {
196            AnySigningKey::K256(k) => {
197                // k256's sign_prehash already returns a low-s normalized
198                // signature (BIP-0062 enforcement is built in). Returns an
199                // ecdsa::Signature.
200                let sig: k256::ecdsa::Signature = K256PrehashSigner::sign_prehash(k, prehash)
201                    .expect("SHA-256 output is always 32 bytes");
202                AnySignature::K256(sig)
203            }
204            AnySigningKey::P256(k) => {
205                // p256's sign_prehash may return a high-s signature;
206                // normalize explicitly.
207                let sig: p256::ecdsa::Signature = P256PrehashSigner::sign_prehash(k, prehash)
208                    .expect("SHA-256 output is always 32 bytes");
209                let normalized = sig.normalize_s().unwrap_or(sig);
210                AnySignature::P256(normalized)
211            }
212        }
213    }
214}
215
216/// A signature that may be one of several supported curves.
217#[derive(Debug, Clone)]
218pub enum AnySignature {
219    /// secp256k1 signature.
220    K256(k256::ecdsa::Signature),
221    /// P-256 signature.
222    P256(p256::ecdsa::Signature),
223}
224
225impl AnySignature {
226    /// Serializes the signature bytes for JWS compact form: raw `r || s`
227    /// big-endian concatenation (NOT DER).
228    ///
229    /// For both ES256 and ES256K this is a 64-byte fixed-length array.
230    pub fn to_jws_bytes(&self) -> [u8; 64] {
231        match self {
232            AnySignature::K256(s) => s.to_bytes().into(),
233            AnySignature::P256(s) => s.to_bytes().into(),
234        }
235    }
236}
237
238/// Error from signature verification across multiple curves.
239#[derive(Debug, thiserror::Error)]
240pub enum AnySignatureError {
241    /// secp256k1 signature error.
242    #[error("secp256k1 signature verification failed")]
243    K256(#[source] k256::ecdsa::Error),
244    /// P-256 signature error.
245    #[error("P-256 signature verification failed")]
246    P256(#[source] p256::ecdsa::Error),
247    /// Signature and key use mismatched curves.
248    #[error("Signature and key use mismatched curves")]
249    CurveMismatch,
250}
251
252/// A multikey public key, parsed and ready for signature verification.
253#[derive(Debug, Clone)]
254pub struct ParsedMultikey {
255    /// The verifying key.
256    pub verifying_key: AnyVerifyingKey,
257}
258
259/// Errors during identity resolution.
260#[derive(Debug, Error)]
261pub enum IdentityError {
262    /// Handle was syntactically invalid.
263    #[error("Invalid handle format")]
264    InvalidHandle,
265
266    /// Handle could not be resolved via DNS or HTTPS.
267    #[error("Handle could not be resolved")]
268    HandleUnresolvable {
269        /// DNS lookup error (if any).
270        dns_error: Option<Box<IdentityError>>,
271        /// HTTPS fallback error (if any).
272        http_error: Option<Box<IdentityError>>,
273    },
274
275    /// DNS lookup failed.
276    #[error("DNS lookup failed")]
277    DnsLookupFailed {
278        /// The underlying DNS error.
279        #[source]
280        source: Box<IdentityError>,
281    },
282
283    /// DNS backend resolver error.
284    #[error("DNS backend error")]
285    DnsBackend(#[from] hickory_resolver::ResolveError),
286
287    /// HTTPS fallback for handle resolution failed.
288    #[error("HTTP fallback for handle resolution failed")]
289    HandleHttpFallbackFailed {
290        /// The underlying HTTP error.
291        #[source]
292        source: Box<IdentityError>,
293    },
294
295    /// DID method is not supported.
296    #[error("Unsupported DID method: {method}")]
297    UnsupportedDidMethod { method: String },
298
299    /// DID resolution failed with a non-200 status.
300    #[error("DID resolution failed with status {status}")]
301    DidResolutionFailed {
302        /// The HTTP status code.
303        status: u16,
304        /// The response body.
305        body: String,
306    },
307
308    /// DNS record exists but contains no DID entry.
309    #[error("DNS record for {handle} has no did= entry")]
310    DnsNoDidRecord {
311        /// The handle that was queried.
312        handle: String,
313    },
314
315    /// DID body is invalid or malformed.
316    #[error("Invalid DID body: {body}")]
317    InvalidDidBody {
318        /// The invalid body content.
319        body: String,
320    },
321
322    /// DID document could not be decoded.
323    #[error("DID document decode failed")]
324    DidDocumentDecodeFailed {
325        /// The source name for diagnostics.
326        source_name: String,
327        /// The raw bytes for diagnostics.
328        source_bytes: Arc<[u8]>,
329        /// The JSON parse error.
330        #[source]
331        cause: serde_json::Error,
332    },
333
334    /// Multikey decoding failed.
335    #[error("Multikey decoding failed")]
336    MultikeyDecodeFailed {
337        /// The underlying error.
338        #[source]
339        source: Box<IdentityError>,
340    },
341
342    /// Multibase encoding was not supported.
343    #[error("Unsupported multibase encoding")]
344    UnsupportedMultibase(String),
345
346    /// Curve is not supported.
347    #[error("Unsupported curve")]
348    UnsupportedCurve { codec_prefix: Vec<u8> },
349
350    /// Multikey length was invalid.
351    #[error("Invalid multikey length")]
352    MultikeyLengthInvalid,
353
354    /// HTTP transport error from reqwest.
355    #[error("HTTP transport error")]
356    HttpTransport(#[from] reqwest::Error),
357}
358
359/// Trait for HTTP clients used by identity resolution.
360///
361/// Narrow interface allowing test fixtures to be injected.
362#[async_trait]
363pub trait HttpClient: Send + Sync {
364    /// Fetches the bytes at the given URL, returning status code and body.
365    async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>;
366}
367
368/// Trait for DNS resolvers used by identity resolution.
369///
370/// Narrow interface allowing test fixtures to be injected.
371#[async_trait]
372pub trait DnsResolver: Send + Sync {
373    /// Performs a TXT lookup on the given name.
374    async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError>;
375}
376
377/// Real HTTP client using reqwest.
378pub struct RealHttpClient {
379    inner: reqwest::Client,
380}
381
382impl RealHttpClient {
383    /// Creates a new HTTP client with a conservative 10-second timeout.
384    ///
385    /// Uses rustls for TLS (per Cargo.toml `rustls-tls` feature) rather than native-tls
386    /// to avoid linking against OpenSSL. Sets a User-Agent header for HTTPS requests.
387    pub fn new() -> Result<Self, IdentityError> {
388        let client = reqwest::Client::builder()
389            .use_rustls_tls()
390            .user_agent(APP_USER_AGENT)
391            .timeout(std::time::Duration::from_secs(10))
392            .build()?;
393        Ok(Self { inner: client })
394    }
395
396    /// Creates a new HTTP client from an existing reqwest::Client.
397    ///
398    /// Allows sharing a single client instance across multiple stages.
399    pub fn from_client(client: reqwest::Client) -> Self {
400        Self { inner: client }
401    }
402}
403
404#[async_trait]
405impl HttpClient for RealHttpClient {
406    async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
407        let response = self.inner.get(url.clone()).send().await?;
408        let status = response.status().as_u16();
409        let bytes = response.bytes().await?;
410        Ok((status, bytes.to_vec()))
411    }
412}
413
414/// Real DNS resolver using hickory-resolver.
415pub struct RealDnsResolver {
416    inner: hickory_resolver::TokioResolver,
417}
418
419impl RealDnsResolver {
420    /// Creates a new DNS resolver with system configuration.
421    pub fn new() -> Self {
422        let resolver = hickory_resolver::Resolver::builder_tokio()
423            .expect("failed to build DNS resolver")
424            .build();
425        Self { inner: resolver }
426    }
427}
428
429impl Default for RealDnsResolver {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435#[async_trait]
436impl DnsResolver for RealDnsResolver {
437    async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
438        let lookup = self.inner.txt_lookup(name).await?;
439        lookup
440            .iter()
441            .map(|record| {
442                let text = record
443                    .iter()
444                    .map(|data| {
445                        String::from_utf8(data.to_vec()).unwrap_or_else(|_| {
446                            tracing::debug!(
447                                target = "atproto_devtool::identity",
448                                "dropping non-UTF-8 TXT record data"
449                            );
450                            String::new()
451                        })
452                    })
453                    .collect::<Vec<_>>()
454                    .join("");
455                Ok(text)
456            })
457            .collect()
458    }
459}
460
461/// Resolves an ATProto handle to a DID via DNS or HTTPS fallback.
462///
463/// Attempts DNS lookup on `_atproto.<handle>` for a `did=...` record,
464/// then falls back to HTTPS GET on `https://<handle>/.well-known/atproto-did`.
465pub async fn resolve_handle(
466    handle: &str,
467    http: &dyn HttpClient,
468    dns: &dyn DnsResolver,
469) -> Result<Did, IdentityError> {
470    // Validate handle format: at least one dot, all ASCII, no leading/trailing dot.
471    if handle.is_empty()
472        || handle.starts_with('.')
473        || handle.ends_with('.')
474        || !handle.is_ascii()
475        || !handle.contains('.')
476    {
477        return Err(IdentityError::InvalidHandle);
478    }
479
480    tracing::debug!(
481        target = "atproto_devtool::identity",
482        handle = %handle,
483        "resolving handle"
484    );
485
486    // DNS path: look up _atproto.<handle> for did= records.
487    let dns_name = format!("_atproto.{handle}");
488
489    let dns_error_opt = match dns.txt_lookup(&dns_name).await {
490        Ok(records) => {
491            // Check if any record contains a did= entry.
492            for record in records {
493                let trimmed = record.trim();
494                if let Some(did_str) = trimmed.strip_prefix("did=") {
495                    let did = Did(did_str.to_string());
496                    tracing::debug!(
497                        target = "atproto_devtool::identity",
498                        did = %did,
499                        "resolved handle via DNS"
500                    );
501                    return Ok(did);
502                }
503            }
504            // Records exist but no did= entry found; populate error for fallback.
505            Some(Box::new(IdentityError::DnsNoDidRecord {
506                handle: handle.to_string(),
507            }))
508        }
509        Err(e) => Some(Box::new(e)),
510    };
511
512    // HTTPS fallback: GET https://<handle>/.well-known/atproto-did.
513    let url = format!("https://{handle}/.well-known/atproto-did");
514    let url = url
515        .parse::<Url>()
516        .map_err(|_| IdentityError::InvalidHandle)?;
517
518    let http_error_opt = match http.get_bytes(&url).await {
519        Ok((200, bytes)) => {
520            let did_str = String::from_utf8_lossy(&bytes).trim().to_string();
521            if !did_str.is_empty() && did_str.starts_with("did:") {
522                let did = Did(did_str);
523                tracing::debug!(
524                    target = "atproto_devtool::identity",
525                    did = %did,
526                    "resolved handle via HTTPS"
527                );
528                return Ok(did);
529            } else {
530                Some(Box::new(IdentityError::HandleHttpFallbackFailed {
531                    source: Box::new(IdentityError::InvalidDidBody { body: did_str }),
532                }))
533            }
534        }
535        Ok((status, bytes)) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
536            source: Box::new(IdentityError::DidResolutionFailed {
537                status,
538                body: String::from_utf8_lossy(&bytes).to_string(),
539            }),
540        })),
541        Err(e) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
542            source: Box::new(e),
543        })),
544    };
545
546    // Both paths failed.
547    Err(IdentityError::HandleUnresolvable {
548        dns_error: dns_error_opt,
549        http_error: http_error_opt,
550    })
551}
552
553/// Resolves a DID to a parsed DID document with raw bytes.
554///
555/// Returns both the parsed document and the raw bytes for use in diagnostics.
556pub async fn resolve_did(
557    did: &Did,
558    http: &dyn HttpClient,
559) -> Result<RawDidDocument, IdentityError> {
560    tracing::debug!(
561        target = "atproto_devtool::identity",
562        did = %did,
563        "resolving DID"
564    );
565
566    let (url, source_name) = match did.method() {
567        DidMethod::Plc => {
568            // did:plc identifiers are base32-like and contain only URL-safe characters.
569            // No additional percent-encoding is needed.
570            let did_str = &did.0;
571            let url_str = format!("https://plc.directory/{did_str}");
572            let url = url_str
573                .parse::<Url>()
574                .map_err(|_| IdentityError::DidResolutionFailed {
575                    status: 400,
576                    body: "Invalid DID format".to_string(),
577                })?;
578            (url.clone(), url.to_string())
579        }
580        DidMethod::Web => {
581            // Strip did:web: prefix and URL-decode path segments.
582            let rest = did.0.strip_prefix("did:web:").unwrap_or("");
583            let parts: Vec<&str> = rest.split(':').collect();
584
585            if parts.is_empty() {
586                return Err(IdentityError::DidResolutionFailed {
587                    status: 400,
588                    body: "Invalid did:web format".to_string(),
589                });
590            }
591
592            let host = parts[0];
593            let path_parts = &parts[1..];
594
595            let url_str = if path_parts.is_empty() {
596                format!("https://{host}/.well-known/did.json")
597            } else {
598                let path = path_parts
599                    .iter()
600                    .map(|p| percent_decode_str(p).unwrap_or_default())
601                    .collect::<Vec<_>>()
602                    .join("/");
603                format!("https://{host}/{path}/did.json")
604            };
605
606            let url = url_str
607                .parse::<Url>()
608                .map_err(|_| IdentityError::DidResolutionFailed {
609                    status: 400,
610                    body: "Invalid URL".to_string(),
611                })?;
612            (url.clone(), url.to_string())
613        }
614        DidMethod::Other => {
615            return Err(IdentityError::UnsupportedDidMethod {
616                method: did.0.clone(),
617            });
618        }
619    };
620
621    let (status, bytes) = http.get_bytes(&url).await?;
622
623    if status != 200 {
624        return Err(IdentityError::DidResolutionFailed {
625            status,
626            body: String::from_utf8_lossy(&bytes).to_string(),
627        });
628    }
629
630    tracing::debug!(
631        target = "atproto_devtool::identity",
632        bytes_len = bytes.len(),
633        "fetched DID document"
634    );
635
636    let parsed = serde_json::from_slice::<DidDocument>(&bytes).map_err(|e| {
637        IdentityError::DidDocumentDecodeFailed {
638            source_name: source_name.clone(),
639            source_bytes: Arc::from(bytes.clone()),
640            cause: e,
641        }
642    })?;
643
644    Ok(RawDidDocument {
645        parsed,
646        source_bytes: Arc::from(bytes),
647        source_name,
648    })
649}
650
651/// Finds a service in a DID document by fragment and type.
652///
653/// Matches both `"#fragment"` and `"did:..#fragment"` ID forms.
654pub fn find_service<'a>(
655    doc: &'a DidDocument,
656    id_fragment: &str,
657    expected_type: &str,
658) -> Option<&'a Service> {
659    let services = doc.service.as_ref()?;
660
661    for service in services {
662        // Extract the fragment after '#', matching both forms:
663        // - "#fragment" (short form)
664        // - "did:...#fragment" (full form)
665        let frag = service.id.rsplit_once('#').map(|(_, f)| f);
666        if frag == Some(id_fragment) && service.type_ == expected_type {
667            return Some(service);
668        }
669    }
670
671    None
672}
673
674/// Parses a multikey string into a verifying key.
675///
676/// Accepts either a bare base58btc multibase string (`z…`) or a
677/// `did:key:z…` form. The latter is how PLC audit logs and some DID
678/// documents surface verification methods, so stripping the prefix here
679/// means every caller can stay agnostic to the wire shape.
680pub fn parse_multikey(raw: &str) -> Result<ParsedMultikey, IdentityError> {
681    tracing::debug!(target = "atproto_devtool::identity", "parsing multikey");
682
683    let multibase_str = raw.strip_prefix("did:key:").unwrap_or(raw);
684
685    let (base, bytes) =
686        multibase::decode(multibase_str).map_err(|_| IdentityError::MultikeyDecodeFailed {
687            source: Box::new(IdentityError::UnsupportedMultibase(
688                "failed to decode multibase".to_string(),
689            )),
690        })?;
691
692    // Require base58btc encoding.
693    if base != multibase::Base::Base58Btc {
694        return Err(IdentityError::UnsupportedMultibase(
695            "multikey must use base58btc encoding".to_string(),
696        ));
697    }
698
699    if bytes.len() < 2 {
700        return Err(IdentityError::MultikeyLengthInvalid);
701    }
702
703    // Parse the varint curve identifier (two bytes for both supported curves).
704    let curve_bytes = [bytes[0], bytes[1]];
705    let rest = &bytes[2..];
706
707    match curve_bytes {
708        // secp256k1-pub (0xe7 0x01)
709        [0xe7, 0x01] => {
710            if rest.len() != 33 {
711                return Err(IdentityError::MultikeyLengthInvalid);
712            }
713            let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
714                IdentityError::MultikeyDecodeFailed {
715                    source: Box::new(IdentityError::MultikeyLengthInvalid),
716                }
717            })?;
718            tracing::debug!(
719                target = "atproto_devtool::identity",
720                curve = "secp256k1",
721                "parsed multikey"
722            );
723            Ok(ParsedMultikey {
724                verifying_key: AnyVerifyingKey::K256(key),
725            })
726        }
727        // p256-pub (0x80 0x24)
728        [0x80, 0x24] => {
729            if rest.len() != 33 {
730                return Err(IdentityError::MultikeyLengthInvalid);
731            }
732            let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
733                IdentityError::MultikeyDecodeFailed {
734                    source: Box::new(IdentityError::MultikeyLengthInvalid),
735                }
736            })?;
737            tracing::debug!(
738                target = "atproto_devtool::identity",
739                curve = "p256",
740                "parsed multikey"
741            );
742            Ok(ParsedMultikey {
743                verifying_key: AnyVerifyingKey::P256(key),
744            })
745        }
746        _ => Err(IdentityError::UnsupportedCurve {
747            codec_prefix: curve_bytes.to_vec(),
748        }),
749    }
750}
751
752/// Encode an `AnyVerifyingKey` as the atproto multibase-multikey format:
753/// base58btc multibase prefix `z`, multicodec curve prefix, compressed SEC1
754/// public key bytes.
755///
756/// See <https://atproto.com/specs/cryptography>. The inverse of `parse_multikey`.
757pub fn encode_multikey(key: &AnyVerifyingKey) -> String {
758    // Multicodec varint prefixes (see https://github.com/multiformats/multicodec).
759    const SECP256K1_PUB: &[u8] = &[0xe7, 0x01];
760    const P256_PUB: &[u8] = &[0x80, 0x24];
761
762    let (prefix, compressed): (&[u8], Vec<u8>) = match key {
763        AnyVerifyingKey::K256(k) => {
764            let point = k.to_encoded_point(true);
765            (SECP256K1_PUB, point.as_bytes().to_vec())
766        }
767        AnyVerifyingKey::P256(k) => {
768            let point = k.to_encoded_point(true);
769            (P256_PUB, point.as_bytes().to_vec())
770        }
771    };
772
773    let mut buf = Vec::with_capacity(prefix.len() + compressed.len());
774    buf.extend_from_slice(prefix);
775    buf.extend_from_slice(&compressed);
776    multibase::encode(multibase::Base::Base58Btc, &buf)
777}
778
779/// A historic key entry from a PLC audit log for a given verification method fragment.
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct PlcHistoricKey {
782    /// The multikey string (the raw key material, not a DID fragment).
783    pub key_id: String,
784    /// The CID of the operation that introduced this key.
785    pub operation_cid: String,
786    /// ISO8601 timestamp from the operation.
787    pub introduced_at: String,
788    /// Whether this key was nullified.
789    pub nullified: bool,
790}
791
792/// Fetches the PLC audit log for a DID and extracts historic keys for a fragment.
793///
794/// Only valid for `did:plc:` DIDs. Panics in debug for other methods; returns
795/// `Err(UnsupportedDidMethod)` in release. Returns keys in chronological order
796/// (oldest-first), matching the PLC API wire order. The caller treats the
797/// result as a set — iteration order does not affect verification correctness.
798///
799/// Transport errors are propagated as `HttpTransport`; decode errors as `DidDocumentDecodeFailed`.
800pub async fn plc_history_for_fragment(
801    did: &Did,
802    fragment: &str,
803    http: &dyn HttpClient,
804) -> Result<Vec<PlcHistoricKey>, IdentityError> {
805    debug_assert!(
806        did.method() == DidMethod::Plc,
807        "plc_history_for_fragment called with non-plc DID: {did}"
808    );
809
810    if did.method() != DidMethod::Plc {
811        return Err(IdentityError::UnsupportedDidMethod {
812            method: format!("{:?}", did.method()),
813        });
814    }
815
816    // Construct the audit log URL: https://plc.directory/{did}/log/audit
817    let audit_url = format!("https://plc.directory/{did}/log/audit");
818    let url = Url::parse(&audit_url).map_err(|_| IdentityError::DidResolutionFailed {
819        status: 400,
820        body: "Invalid PLC audit URL".to_string(),
821    })?;
822
823    let (status, bytes) = http.get_bytes(&url).await?;
824
825    if status != 200 {
826        return Err(IdentityError::DidResolutionFailed {
827            status,
828            body: format!("PLC audit log fetch returned status {status}"),
829        });
830    }
831
832    // Parse the JSON array of operations.
833    let operations: Vec<serde_json::Value> =
834        serde_json::from_slice(&bytes).map_err(|cause| IdentityError::DidDocumentDecodeFailed {
835            source_name: "plc audit log".to_string(),
836            source_bytes: Arc::from(bytes.into_boxed_slice()),
837            cause,
838        })?;
839
840    let mut historic_keys: Vec<PlcHistoricKey> = Vec::new();
841
842    // Walk operations in wire order (oldest-first). Deduplicate by multikey
843    // string: PLC audit logs carry one entry per operation, so a single
844    // persistent key is repeated on every unrelated rotation. Callers want
845    // the set of distinct keys; the dedup keeps the earliest introduction.
846    for op in operations {
847        // Extract the verificationMethods object from operation.
848        let vm = match op
849            .get("operation")
850            .and_then(|o| o.get("verificationMethods"))
851        {
852            Some(vm) => vm,
853            None => continue,
854        };
855
856        // Look for the requested fragment.
857        if let Some(multikey_value) = vm.get(fragment) {
858            let multikey_str = match multikey_value.as_str() {
859                Some(s) => s.to_string(),
860                None => continue,
861            };
862
863            let operation_cid = op
864                .get("cid")
865                .and_then(|c| c.as_str())
866                .unwrap_or("unknown")
867                .to_string();
868
869            // Extract the timestamp from the operation if available.
870            let introduced_at = op
871                .get("operation")
872                .and_then(|o| o.get("createdAt"))
873                .and_then(|c| c.as_str())
874                .unwrap_or("unknown")
875                .to_string();
876
877            let nullified = op
878                .get("nullified")
879                .and_then(|n| n.as_bool())
880                .unwrap_or(false);
881
882            if historic_keys.iter().any(|k| k.key_id == multikey_str) {
883                continue;
884            }
885
886            historic_keys.push(PlcHistoricKey {
887                key_id: multikey_str,
888                operation_cid,
889                introduced_at,
890                nullified,
891            });
892        }
893    }
894
895    Ok(historic_keys)
896}
897
898/// Helper to percent-decode a string.
899fn percent_decode_str(s: &str) -> Result<String, IdentityError> {
900    let decoded = percent_encoding::percent_decode_str(s)
901        .decode_utf8()
902        .map_err(|_| IdentityError::DidResolutionFailed {
903            status: 400,
904            body: "Invalid UTF-8 in percent-encoded path".to_string(),
905        })?;
906    Ok(decoded.to_string())
907}
908
909/// Classify a URL's hostname as "locally reachable from the tool's
910/// machine" for the purposes of self-mint `did:web` viability.
911///
912/// Returns `true` when the hostname is one of:
913/// - `localhost` (case-insensitive)
914/// - `127.0.0.1` (or any IPv4 loopback / `::1`)
915/// - Any `.local` mDNS suffix (case-insensitive)
916/// - Any RFC 1918 IPv4 private address (10/8, 172.16/12, 192.168/16)
917///
918/// Returns `false` for all other hostnames. IPv6 private ranges (fc00::/7,
919/// link-local) are deliberately NOT classified as local in v1; revisit if
920/// users report issues.
921pub fn is_local_labeler_hostname(url: &Url) -> bool {
922    use url::Host;
923
924    let host = match url.host() {
925        Some(h) => h,
926        None => return false,
927    };
928
929    match host {
930        Host::Ipv4(addr) => addr.is_loopback() || addr.is_private(),
931        Host::Ipv6(addr) => addr.is_loopback(),
932        Host::Domain(domain) => {
933            let lower = domain.to_ascii_lowercase();
934            if lower == "localhost" {
935                return true;
936            }
937            if lower.ends_with(".local") {
938                return true;
939            }
940            false
941        }
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948    use k256::ecdsa::SigningKey as K256SigningKey;
949    use k256::ecdsa::signature::hazmat::PrehashSigner;
950    use p256::ecdsa::SigningKey as P256SigningKey;
951    use sha2::Digest;
952    use std::collections::HashMap;
953
954    /// Response variant for FakeHttpClient.
955    #[derive(Clone)]
956    enum Response {
957        /// HTTP response with status code and body.
958        Http(u16, Vec<u8>),
959        /// Transport error (network-level failure).
960        Transport(String),
961    }
962
963    /// Fake HTTP client for testing, built from a map of URL → response.
964    struct FakeHttpClient {
965        responses: HashMap<String, Response>,
966    }
967
968    #[async_trait]
969    impl HttpClient for FakeHttpClient {
970        async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
971            match self.responses.get(url.as_str()).cloned() {
972                Some(Response::Http(status, body)) => Ok((status, body)),
973                Some(Response::Transport(message)) => {
974                    // Simulate a transport error by returning a synthesized status 0.
975                    // We cannot construct a real reqwest::Error in unit tests, so the
976                    // message is threaded into the body so assertions can inspect it.
977                    Err(IdentityError::DidResolutionFailed {
978                        status: 0,
979                        body: format!("Transport error: {message}"),
980                    })
981                }
982                None => Err(IdentityError::DidResolutionFailed {
983                    status: 404,
984                    body: "Not found".to_string(),
985                }),
986            }
987        }
988    }
989
990    /// Fake DNS resolver for testing, built from a map of name → records.
991    struct FakeDnsResolver {
992        records: HashMap<String, Vec<String>>,
993    }
994
995    #[async_trait]
996    impl DnsResolver for FakeDnsResolver {
997        async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
998            self.records
999                .get(name)
1000                .cloned()
1001                .ok_or_else(|| IdentityError::DnsLookupFailed {
1002                    source: Box::new(IdentityError::InvalidHandle),
1003                })
1004        }
1005    }
1006
1007    #[tokio::test]
1008    async fn resolve_handle_via_dns() {
1009        let mut records = HashMap::new();
1010        records.insert(
1011            "_atproto.alice.example".to_string(),
1012            vec!["did=did:plc:abc123".to_string()],
1013        );
1014        let dns = FakeDnsResolver { records };
1015        let http = FakeHttpClient {
1016            responses: HashMap::new(),
1017        };
1018
1019        let result = resolve_handle("alice.example", &http, &dns).await;
1020
1021        assert!(result.is_ok());
1022        let did = result.unwrap();
1023        assert_eq!(did.0, "did:plc:abc123");
1024    }
1025
1026    #[tokio::test]
1027    async fn resolve_handle_via_https_fallback() {
1028        let dns = FakeDnsResolver {
1029            records: HashMap::new(),
1030        };
1031        let mut responses = HashMap::new();
1032        responses.insert(
1033            "https://alice.example/.well-known/atproto-did".to_string(),
1034            Response::Http(200, b"did:plc:abc123\n".to_vec()),
1035        );
1036        let http = FakeHttpClient { responses };
1037
1038        let result = resolve_handle("alice.example", &http, &dns).await;
1039
1040        assert!(result.is_ok());
1041        let did = result.unwrap();
1042        assert_eq!(did.0, "did:plc:abc123");
1043    }
1044
1045    #[tokio::test]
1046    async fn resolve_handle_both_paths_fail() {
1047        let dns = FakeDnsResolver {
1048            records: HashMap::new(),
1049        };
1050        let http = FakeHttpClient {
1051            responses: HashMap::new(),
1052        };
1053
1054        let result = resolve_handle("alice.example", &http, &dns).await;
1055
1056        assert!(result.is_err());
1057        match result.unwrap_err() {
1058            IdentityError::HandleUnresolvable {
1059                dns_error,
1060                http_error,
1061            } => {
1062                assert!(dns_error.is_some());
1063                assert!(http_error.is_some());
1064            }
1065            _ => panic!("Expected HandleUnresolvable error"),
1066        }
1067    }
1068
1069    #[tokio::test]
1070    async fn resolve_did_plc_success() {
1071        let plc_doc = include_bytes!("../../tests/fixtures/identity/plc_bsky_labeler.json");
1072        let mut responses = HashMap::new();
1073        responses.insert(
1074            "https://plc.directory/did:plc:test-labeler".to_string(),
1075            Response::Http(200, plc_doc.to_vec()),
1076        );
1077        let http = FakeHttpClient { responses };
1078
1079        let did = Did("did:plc:test-labeler".to_string());
1080        let raw_doc = resolve_did(&did, &http).await.expect("resolve_did");
1081        assert_eq!(raw_doc.parsed.id, "did:plc:test-labeler");
1082        assert!(raw_doc.source_bytes.as_ref() == plc_doc);
1083        assert_eq!(
1084            raw_doc.source_name,
1085            "https://plc.directory/did:plc:test-labeler"
1086        );
1087
1088        // Verify both services are present.
1089        let services = raw_doc.parsed.service.as_ref().expect("services");
1090        assert!(
1091            services.iter().any(|s| s.type_ == "AtprotoLabeler"),
1092            "fixture must contain a labeler service"
1093        );
1094        assert!(
1095            services
1096                .iter()
1097                .any(|s| s.type_ == "AtprotoPersonalDataServer"),
1098            "fixture must contain a PDS service"
1099        );
1100
1101        // Verify both verification methods are present.
1102        let vms = raw_doc
1103            .parsed
1104            .verification_method
1105            .as_ref()
1106            .expect("verificationMethod");
1107        assert!(
1108            vms.iter().any(|vm| vm.id == "#atproto"),
1109            "fixture must contain a repo signing key"
1110        );
1111        assert!(
1112            vms.iter().any(|vm| vm.id == "#atproto_label"),
1113            "fixture must contain a label signing key"
1114        );
1115    }
1116
1117    #[tokio::test]
1118    async fn resolve_did_web_success() {
1119        let web_doc = include_bytes!("../../tests/fixtures/identity/web_example.json");
1120        let mut responses = HashMap::new();
1121        responses.insert(
1122            "https://example.com/.well-known/did.json".to_string(),
1123            Response::Http(200, web_doc.to_vec()),
1124        );
1125        let http = FakeHttpClient { responses };
1126
1127        let did = Did("did:web:example.com".to_string());
1128        let result = resolve_did(&did, &http).await;
1129
1130        assert!(result.is_ok());
1131        let raw_doc = result.unwrap();
1132        assert_eq!(raw_doc.parsed.id, "did:web:example.com");
1133        assert_eq!(
1134            raw_doc.source_name,
1135            "https://example.com/.well-known/did.json"
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn resolve_did_decode_failure_preserves_bytes() {
1141        let bad_json = b"not valid json";
1142        let mut responses = HashMap::new();
1143        responses.insert(
1144            "https://plc.directory/did:plc:bad".to_string(),
1145            Response::Http(200, bad_json.to_vec()),
1146        );
1147        let http = FakeHttpClient { responses };
1148
1149        let did = Did("did:plc:bad".to_string());
1150        let result = resolve_did(&did, &http).await;
1151
1152        assert!(result.is_err());
1153        match result.unwrap_err() {
1154            IdentityError::DidDocumentDecodeFailed {
1155                source_name: _,
1156                source_bytes,
1157                cause: _,
1158            } => {
1159                assert_eq!(source_bytes.as_ref(), bad_json);
1160            }
1161            _ => panic!("Expected DidDocumentDecodeFailed error"),
1162        }
1163    }
1164
1165    #[test]
1166    fn find_service_matches_both_id_forms() {
1167        let doc = DidDocument {
1168            id: "did:plc:abc".to_string(),
1169            also_known_as: None,
1170            verification_method: None,
1171            service: Some(vec![
1172                Service {
1173                    id: "did:plc:abc#atproto_labeler".to_string(),
1174                    type_: "AtprotoLabeler".to_string(),
1175                    service_endpoint: "https://example.com/labeler".to_string(),
1176                },
1177                Service {
1178                    id: "#atproto_pds".to_string(),
1179                    type_: "AtprotoPersonalDataServer".to_string(),
1180                    service_endpoint: "https://example.com/pds".to_string(),
1181                },
1182                // Service with a name that contains the search fragment as a substring.
1183                Service {
1184                    id: "#xatproto_labeler".to_string(),
1185                    type_: "OtherType".to_string(),
1186                    service_endpoint: "https://example.com/other".to_string(),
1187                },
1188            ]),
1189        };
1190
1191        let labeler = find_service(&doc, "atproto_labeler", "AtprotoLabeler");
1192        assert!(labeler.is_some());
1193        let labeler = labeler.unwrap();
1194        assert_eq!(labeler.id, "did:plc:abc#atproto_labeler");
1195
1196        let pds = find_service(&doc, "atproto_pds", "AtprotoPersonalDataServer");
1197        assert!(pds.is_some());
1198        let pds = pds.unwrap();
1199        assert_eq!(pds.id, "#atproto_pds");
1200
1201        // Ensure false-positive on substring is not matched.
1202        let wrong = find_service(&doc, "atproto_labeler", "OtherType");
1203        assert!(wrong.is_none());
1204    }
1205
1206    #[test]
1207    fn find_service_type_mismatch_returns_none() {
1208        let doc = DidDocument {
1209            id: "did:plc:abc".to_string(),
1210            also_known_as: None,
1211            verification_method: None,
1212            service: Some(vec![Service {
1213                id: "#atproto_labeler".to_string(),
1214                type_: "AtprotoLabeler".to_string(),
1215                service_endpoint: "https://example.com/labeler".to_string(),
1216            }]),
1217        };
1218
1219        let result = find_service(&doc, "atproto_labeler", "WrongType");
1220        assert!(result.is_none());
1221    }
1222
1223    #[test]
1224    fn parse_multikey_k256() {
1225        // Generated from an ephemeral k256 keypair.
1226        // These keys are not production keys; they are synthetic fixtures for testing.
1227        let multikey = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
1228
1229        let result = parse_multikey(multikey);
1230        assert!(result.is_ok());
1231
1232        let parsed = result.unwrap();
1233
1234        // Verify the key can be extracted and its bytes match the expected value.
1235        match &parsed.verifying_key {
1236            AnyVerifyingKey::K256(key) => {
1237                let sec1_bytes = key.to_sec1_bytes();
1238                assert_eq!(sec1_bytes.len(), 33); // Compressed form is 33 bytes.
1239                // Expected bytes derived from the multikey fixture.
1240                // zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9 decodes to:
1241                // [0xe7, 0x01] (k256 prefix) + 33-byte SEC1 point.
1242                let expected_hex =
1243                    "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1244                let actual_hex = sec1_bytes.iter().fold(String::new(), |mut s, b| {
1245                    use std::fmt::Write;
1246                    let _ = write!(s, "{b:02x}");
1247                    s
1248                });
1249                assert_eq!(actual_hex, expected_hex);
1250            }
1251            _ => panic!("Expected K256 verifying key"),
1252        }
1253    }
1254
1255    #[test]
1256    fn parse_multikey_p256() {
1257        // Generated from an ephemeral p256 keypair.
1258        // These keys are not production keys; they are synthetic fixtures for testing.
1259        let multikey = include_str!("../../tests/fixtures/identity/multikey_p256.txt").trim();
1260
1261        let result = parse_multikey(multikey);
1262        assert!(result.is_ok());
1263
1264        let parsed = result.unwrap();
1265
1266        // Verify the key can be extracted and is the correct type.
1267        match &parsed.verifying_key {
1268            AnyVerifyingKey::P256(key) => {
1269                // Force compressed form for consistent testing.
1270                let sec1_bytes = key.to_encoded_point(true).as_bytes().to_vec();
1271                assert_eq!(sec1_bytes.len(), 33);
1272                // Expected bytes derived from the multikey fixture.
1273                let expected_hex =
1274                    "026b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296";
1275                let actual_hex = sec1_bytes.iter().fold(String::new(), |mut s, b| {
1276                    use std::fmt::Write;
1277                    let _ = write!(s, "{b:02x}");
1278                    s
1279                });
1280                assert_eq!(actual_hex, expected_hex);
1281            }
1282            _ => panic!("Expected P256 verifying key"),
1283        }
1284    }
1285
1286    #[test]
1287    fn parse_multikey_unsupported_curve() {
1288        // Build a multikey with an unsupported curve prefix (0x01 0x00).
1289        let mut unsupported_bytes = vec![0x01, 0x00];
1290        unsupported_bytes.extend_from_slice(&[0; 33]); // Fake 33-byte key.
1291        // multibase::encode already returns the z-prefixed string for Base58Btc.
1292        let multikey = multibase::encode(multibase::Base::Base58Btc, unsupported_bytes);
1293
1294        let result = parse_multikey(&multikey);
1295        assert!(result.is_err());
1296
1297        match result.unwrap_err() {
1298            IdentityError::UnsupportedCurve { codec_prefix: _ } => {}
1299            _ => panic!("Expected UnsupportedCurve error"),
1300        }
1301    }
1302
1303    #[test]
1304    fn parse_multikey_not_base58btc() {
1305        // Use base16 encoding instead of base58btc.
1306        let mut key_bytes = vec![0xe7, 0x01];
1307        key_bytes.extend_from_slice(&[0; 33]);
1308        // Manually create base16 multibase string (f prefix).
1309        let hex_str = key_bytes.iter().fold(String::new(), |mut s, b| {
1310            use std::fmt::Write;
1311            let _ = write!(s, "{b:02x}");
1312            s
1313        });
1314        let multikey = format!("f{hex_str}");
1315
1316        let result = parse_multikey(&multikey);
1317        assert!(result.is_err());
1318
1319        match result.unwrap_err() {
1320            IdentityError::UnsupportedMultibase(_) => {}
1321            _ => panic!("Expected UnsupportedMultibase error"),
1322        }
1323    }
1324
1325    #[test]
1326    fn parse_multikey_accepts_did_key_prefix() {
1327        // The bare multikey parses, and prepending "did:key:" must be equivalent.
1328        // PLC audit logs store verificationMethods values in the did:key form,
1329        // so this path is load-bearing for the crypto stage's history fallback.
1330        let bare = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
1331        let did_key = format!("did:key:{bare}");
1332
1333        let from_bare = parse_multikey(bare).expect("bare multikey should parse");
1334        let from_did_key = parse_multikey(&did_key).expect("did:key multikey should parse");
1335
1336        assert!(matches!(from_bare.verifying_key, AnyVerifyingKey::K256(_)));
1337        assert!(matches!(
1338            from_did_key.verifying_key,
1339            AnyVerifyingKey::K256(_)
1340        ));
1341    }
1342
1343    #[test]
1344    fn parse_multikey_wrong_length() {
1345        // Build a multikey with a 10-byte body instead of 33.
1346        let mut wrong_len_bytes = vec![0x80, 0x24]; // p256 prefix
1347        wrong_len_bytes.extend_from_slice(&[0; 10]); // Only 10 bytes instead of 33
1348
1349        // multibase::encode already returns the z-prefixed string for Base58Btc.
1350        let multikey = multibase::encode(multibase::Base::Base58Btc, &wrong_len_bytes);
1351
1352        let result = parse_multikey(&multikey);
1353        assert!(result.is_err());
1354
1355        // Must strictly assert MultikeyLengthInvalid.
1356        match result.unwrap_err() {
1357            IdentityError::MultikeyLengthInvalid => {
1358                // Correct error variant.
1359            }
1360            e => panic!("Expected MultikeyLengthInvalid, got {e:?}"),
1361        }
1362    }
1363
1364    #[test]
1365    fn verify_prehash_k256_valid() {
1366        // Create an ephemeral k256 keypair for testing.
1367        let signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1368        let verifying_key = signing_key.verifying_key();
1369
1370        // Create a test prehash (32 bytes).
1371        let prehash = *b"01234567890123456789012345678901";
1372
1373        // Sign the prehash.
1374        let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
1375
1376        // Wrap in our generic types.
1377        let any_key = AnyVerifyingKey::K256(*verifying_key);
1378        let any_sig = AnySignature::K256(signature);
1379
1380        // Verify should succeed.
1381        assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
1382    }
1383
1384    #[test]
1385    fn verify_prehash_p256_valid() {
1386        // Create an ephemeral p256 keypair for testing.
1387        let signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1388        let verifying_key = signing_key.verifying_key();
1389
1390        // Create a test prehash (32 bytes).
1391        let prehash = *b"01234567890123456789012345678901";
1392
1393        // Sign the prehash.
1394        let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
1395
1396        // Wrap in our generic types.
1397        let any_key = AnyVerifyingKey::P256(*verifying_key);
1398        let any_sig = AnySignature::P256(signature);
1399
1400        // Verify should succeed.
1401        assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
1402    }
1403
1404    #[test]
1405    fn verify_prehash_curve_mismatch() {
1406        // Test k256 key with p256 signature (should fail).
1407        let k256_signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1408        let k256_key = AnyVerifyingKey::K256(*k256_signing_key.verifying_key());
1409
1410        let p256_signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1411        let prehash = *b"01234567890123456789012345678901";
1412        let p256_sig = p256_signing_key
1413            .sign_prehash(&prehash)
1414            .expect("signing failed");
1415        let p256_any_sig = AnySignature::P256(p256_sig);
1416
1417        // Verify should fail due to curve mismatch.
1418        assert!(k256_key.verify_prehash(&prehash, &p256_any_sig).is_err());
1419
1420        // Test p256 key with k256 signature (symmetric case).
1421        let p256_signing_key_2 =
1422            P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
1423        let p256_key = AnyVerifyingKey::P256(*p256_signing_key_2.verifying_key());
1424
1425        let k256_signing_key_2 =
1426            K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
1427        let k256_sig = k256_signing_key_2
1428            .sign_prehash(&prehash)
1429            .expect("signing failed");
1430        let k256_any_sig = AnySignature::K256(k256_sig);
1431
1432        // Verify should fail due to curve mismatch.
1433        assert!(p256_key.verify_prehash(&prehash, &k256_any_sig).is_err());
1434    }
1435
1436    #[tokio::test]
1437    async fn plc_history_parses_rotation_fixture() {
1438        // Load the fixture with one key rotation.
1439        let fixture_bytes =
1440            include_bytes!("../../tests/fixtures/identity/plc_audit_log_with_rotation.json");
1441        let mut responses = HashMap::new();
1442        responses.insert(
1443            "https://plc.directory/did:plc:test/log/audit".to_string(),
1444            Response::Http(200, fixture_bytes.to_vec()),
1445        );
1446        let http = FakeHttpClient { responses };
1447
1448        let did = Did("did:plc:test".to_string());
1449        let result = plc_history_for_fragment(&did, "atproto_label", &http)
1450            .await
1451            .expect("plc_history should succeed");
1452
1453        // Expect two distinct keys from the fixture, in chronological order.
1454        assert_eq!(result.len(), 2);
1455        assert_eq!(
1456            result[0].key_id,
1457            "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y"
1458        );
1459        assert!(!result[0].nullified);
1460        assert_eq!(
1461            result[1].key_id,
1462            "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"
1463        );
1464        assert!(!result[1].nullified);
1465    }
1466
1467    #[tokio::test]
1468    async fn plc_history_dedupes_repeated_key() {
1469        // Craft an audit log where the same atproto_label multikey appears
1470        // across several operations (e.g. because only unrelated signing
1471        // keys rotated). The caller wants the set of distinct historic keys.
1472        let key = "did:key:zQ3shw6eSipD1cnrmmokVWvKCuE6Yc9j2jAjWJ9nWpuF4yQKV";
1473        let log = serde_json::json!([
1474            {"cid": "op3", "operation": {"verificationMethods": {"atproto_label": key}}},
1475            {"cid": "op2", "operation": {"verificationMethods": {"atproto_label": key}}},
1476            {"cid": "op1", "operation": {"verificationMethods": {"atproto_label": key}}},
1477        ]);
1478        let mut responses = HashMap::new();
1479        responses.insert(
1480            "https://plc.directory/did:plc:dedupe/log/audit".to_string(),
1481            Response::Http(200, serde_json::to_vec(&log).unwrap()),
1482        );
1483        let http = FakeHttpClient { responses };
1484
1485        let did = Did("did:plc:dedupe".to_string());
1486        let result = plc_history_for_fragment(&did, "atproto_label", &http)
1487            .await
1488            .expect("plc_history should succeed");
1489
1490        assert_eq!(result.len(), 1);
1491        assert_eq!(result[0].key_id, key);
1492        // Newest entry wins on dedupe.
1493        assert_eq!(result[0].operation_cid, "op3");
1494    }
1495
1496    #[tokio::test]
1497    #[should_panic(expected = "plc_history_for_fragment called with non-plc DID")]
1498    async fn plc_history_unsupported_method_errors() {
1499        // did:web should panic in debug (due to debug_assert).
1500        // In release, it would return Err(UnsupportedDidMethod).
1501        let mut responses = HashMap::new();
1502        responses.insert(
1503            "https://plc.directory/did:web:example.com/log/audit".to_string(),
1504            Response::Http(200, b"[]".to_vec()),
1505        );
1506        let http = FakeHttpClient { responses };
1507
1508        let did = Did("did:web:example.com".to_string());
1509        let _result = plc_history_for_fragment(&did, "atproto_label", &http).await;
1510    }
1511
1512    #[tokio::test]
1513    async fn plc_history_transport_error_propagates() {
1514        // FakeHttpClient returning a transport error (represented as status 0).
1515        let mut responses = HashMap::new();
1516        responses.insert(
1517            "https://plc.directory/did:plc:test/log/audit".to_string(),
1518            Response::Transport("connection refused".to_string()),
1519        );
1520        let http = FakeHttpClient { responses };
1521
1522        let did = Did("did:plc:test".to_string());
1523        let result = plc_history_for_fragment(&did, "atproto_label", &http).await;
1524
1525        assert!(result.is_err());
1526        // The error should be DidResolutionFailed with status 0 (transport error).
1527        match result.unwrap_err() {
1528            IdentityError::DidResolutionFailed { status, body } => {
1529                assert_eq!(status, 0);
1530                assert_eq!(body, "Transport error: connection refused");
1531            }
1532            e => panic!("Expected DidResolutionFailed with status 0, got {e:?}"),
1533        }
1534    }
1535
1536    #[test]
1537    fn any_signing_key_k256_round_trip() {
1538        let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
1539        let vkey = key.verifying_key();
1540        let msg = b"test message";
1541        let sig = key.sign(msg);
1542        assert!(vkey.verify_prehash(&[0u8; 32], &sig).is_err()); // Wrong prehash should fail.
1543
1544        // Sign the same message and verify.
1545        let sig2 = key.sign(msg);
1546        let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
1547        assert!(vkey.verify_prehash(&hash, &sig2).is_ok());
1548    }
1549
1550    #[test]
1551    fn any_signing_key_p256_round_trip() {
1552        let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
1553        let vkey = key.verifying_key();
1554        let msg = b"test message";
1555        let sig = key.sign(msg);
1556        let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
1557        assert!(vkey.verify_prehash(&hash, &sig).is_ok());
1558    }
1559
1560    #[test]
1561    fn any_signing_key_jwt_alg() {
1562        let k256_key =
1563            AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
1564        let p256_key =
1565            AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
1566
1567        assert_eq!(k256_key.jwt_alg(), "ES256K");
1568        assert_eq!(p256_key.jwt_alg(), "ES256");
1569    }
1570
1571    #[test]
1572    fn any_signature_to_jws_bytes() {
1573        let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
1574        let msg = b"test";
1575        let sig = key.sign(msg);
1576        let jws_bytes = sig.to_jws_bytes();
1577        assert_eq!(jws_bytes.len(), 64);
1578    }
1579
1580    #[test]
1581    fn any_signing_key_p256_signature_is_normalized() {
1582        // Test that P256 signatures produced by AnySigningKey::sign are normalized to low-s.
1583        let key = AnySigningKey::P256(P256SigningKey::from_slice(&[3u8; 32]).expect("valid seed"));
1584        let msg = b"test message for normalization";
1585        let vkey = key.verifying_key();
1586
1587        // Sign and get the signature.
1588        let sig = key.sign(msg);
1589
1590        // Explicitly verify that the signature is low-s (normalized).
1591        if let AnySignature::P256(sig_p256) = &sig {
1592            assert!(
1593                sig_p256.normalize_s().is_none(),
1594                "signature should already be low-s (further normalization should return None)"
1595            );
1596        } else {
1597            unreachable!("signing with P256 key must produce P256 signature");
1598        }
1599
1600        // Also verify that signature verifies correctly.
1601        use sha2::Digest as _;
1602        let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
1603        assert!(
1604            vkey.verify_prehash(&hash, &sig).is_ok(),
1605            "P256 signature should verify after normalization"
1606        );
1607
1608        // Also verify that to_jws_bytes produces a 64-byte result.
1609        let sig_bytes = sig.to_jws_bytes();
1610        assert_eq!(
1611            sig_bytes.len(),
1612            64,
1613            "P256 signature should be 64 bytes after JWS serialization"
1614        );
1615    }
1616
1617    #[test]
1618    fn is_local_labeler_hostname_classifies_expected_hosts() {
1619        let cases: &[(&str, bool)] = &[
1620            // Positive: localhost variants.
1621            ("http://localhost/", true),
1622            ("https://LOCALHOST:8080/foo", true),
1623            ("http://127.0.0.1/", true),
1624            ("http://127.1.2.3/", true),
1625            ("http://[::1]/", true),
1626            // Positive: .local mDNS.
1627            ("http://mybox.local/", true),
1628            ("https://mybox.LOCAL:8443/", true),
1629            // Positive: RFC 1918.
1630            ("http://10.0.0.1/", true),
1631            ("http://172.16.0.1/", true),
1632            ("http://172.31.255.255/", true),
1633            ("http://192.168.1.100/", true),
1634            // Negative: public.
1635            ("https://labeler.example.com/", false),
1636            ("http://8.8.8.8/", false),
1637            ("http://172.15.0.1/", false), // outside 172.16/12
1638            ("http://172.32.0.1/", false), // outside 172.16/12
1639            ("http://11.0.0.1/", false),   // outside 10/8 once we pass 10.x
1640            ("http://172.17.1.1/", true),  // inside 172.16/12
1641        ];
1642        for (url, expected) in cases {
1643            let parsed = Url::parse(url).expect("test URLs are valid");
1644            assert_eq!(
1645                is_local_labeler_hostname(&parsed),
1646                *expected,
1647                "classification mismatch for {url}"
1648            );
1649        }
1650    }
1651
1652    #[test]
1653    fn encode_multikey_round_trip_k256() {
1654        // Create a random k256 signing key, encode its public key, then
1655        // decode it back and verify the keys match.
1656        let signing_key = AnySigningKey::K256(k256::ecdsa::SigningKey::random(
1657            &mut k256::elliptic_curve::rand_core::OsRng,
1658        ));
1659        let original_verifying = signing_key.verifying_key();
1660
1661        // Encode to multikey format.
1662        let encoded = encode_multikey(&original_verifying);
1663        assert!(
1664            encoded.starts_with('z'),
1665            "multikey should start with 'z' (base58btc)"
1666        );
1667
1668        // Decode back and verify.
1669        let parsed = parse_multikey(&encoded).expect("encoded multikey should parse");
1670        match (&original_verifying, &parsed.verifying_key) {
1671            (AnyVerifyingKey::K256(original), AnyVerifyingKey::K256(decoded)) => {
1672                let orig_bytes = original.to_sec1_bytes();
1673                let decoded_bytes = decoded.to_sec1_bytes();
1674                assert_eq!(
1675                    orig_bytes, decoded_bytes,
1676                    "k256 keys should match after round-trip"
1677                );
1678            }
1679            _ => panic!("Expected K256 keys"),
1680        }
1681    }
1682
1683    #[test]
1684    fn encode_multikey_round_trip_p256() {
1685        // Create a random p256 signing key, encode its public key, then
1686        // decode it back and verify the keys match.
1687        let signing_key = AnySigningKey::P256(p256::ecdsa::SigningKey::random(
1688            &mut p256::elliptic_curve::rand_core::OsRng,
1689        ));
1690        let original_verifying = signing_key.verifying_key();
1691
1692        // Encode to multikey format.
1693        let encoded = encode_multikey(&original_verifying);
1694        assert!(
1695            encoded.starts_with('z'),
1696            "multikey should start with 'z' (base58btc)"
1697        );
1698
1699        // Decode back and verify.
1700        let parsed = parse_multikey(&encoded).expect("encoded multikey should parse");
1701        match (&original_verifying, &parsed.verifying_key) {
1702            (AnyVerifyingKey::P256(original), AnyVerifyingKey::P256(decoded)) => {
1703                let orig_bytes = original.to_encoded_point(true).as_bytes().to_vec();
1704                let decoded_bytes = decoded.to_encoded_point(true).as_bytes().to_vec();
1705                assert_eq!(
1706                    orig_bytes, decoded_bytes,
1707                    "p256 keys should match after round-trip"
1708                );
1709            }
1710            _ => panic!("Expected P256 keys"),
1711        }
1712    }
1713}