use async_trait::async_trait;
use k256::ecdsa::signature::hazmat::PrehashVerifier;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
use thiserror::Error;
use url::Url;
use crate::common::APP_USER_AGENT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DidMethod {
Plc,
Web,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Did(pub String);
impl Did {
pub fn method(&self) -> DidMethod {
if self.0.starts_with("did:plc:") {
DidMethod::Plc
} else if self.0.starts_with("did:web:") {
DidMethod::Web
} else {
DidMethod::Other
}
}
}
impl fmt::Display for Did {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationMethod {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
pub controller: String,
#[serde(rename = "publicKeyMultibase", skip_serializing_if = "Option::is_none")]
pub public_key_multibase: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Service {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DidDocument {
pub id: String,
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Option::is_none")]
pub also_known_as: Option<Vec<String>>,
#[serde(rename = "verificationMethod", skip_serializing_if = "Option::is_none")]
pub verification_method: Option<Vec<VerificationMethod>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<Vec<Service>>,
}
#[derive(Debug, Clone)]
pub struct RawDidDocument {
pub parsed: DidDocument,
pub source_bytes: Arc<[u8]>,
pub source_name: String,
}
#[derive(Debug, Clone)]
pub enum AnyVerifyingKey {
K256(k256::ecdsa::VerifyingKey),
P256(p256::ecdsa::VerifyingKey),
}
impl AnyVerifyingKey {
pub fn curve_name(&self) -> &'static str {
match self {
AnyVerifyingKey::K256(_) => "secp256k1",
AnyVerifyingKey::P256(_) => "P-256",
}
}
pub fn verify_prehash(
&self,
prehash: &[u8; 32],
sig: &AnySignature,
) -> Result<(), AnySignatureError> {
match (self, sig) {
(AnyVerifyingKey::K256(key), AnySignature::K256(sig)) => key
.verify_prehash(prehash, sig)
.map_err(AnySignatureError::K256),
(AnyVerifyingKey::P256(key), AnySignature::P256(sig)) => key
.verify_prehash(prehash, sig)
.map_err(AnySignatureError::P256),
_ => Err(AnySignatureError::CurveMismatch),
}
}
}
#[derive(Debug, Clone)]
pub enum AnySigningKey {
K256(k256::ecdsa::SigningKey),
P256(p256::ecdsa::SigningKey),
}
impl AnySigningKey {
pub fn verifying_key(&self) -> AnyVerifyingKey {
match self {
AnySigningKey::K256(k) => AnyVerifyingKey::K256(*k.verifying_key()),
AnySigningKey::P256(k) => AnyVerifyingKey::P256(*k.verifying_key()),
}
}
pub fn jwt_alg(&self) -> &'static str {
match self {
AnySigningKey::K256(_) => "ES256K",
AnySigningKey::P256(_) => "ES256",
}
}
pub fn sign(&self, msg: &[u8]) -> AnySignature {
use sha2::{Digest, Sha256};
let prehash: [u8; 32] = Sha256::digest(msg).into();
self.sign_prehash(&prehash)
}
pub fn sign_prehash(&self, prehash: &[u8; 32]) -> AnySignature {
use k256::ecdsa::signature::hazmat::PrehashSigner as K256PrehashSigner;
use p256::ecdsa::signature::hazmat::PrehashSigner as P256PrehashSigner;
match self {
AnySigningKey::K256(k) => {
let sig: k256::ecdsa::Signature = K256PrehashSigner::sign_prehash(k, prehash)
.expect("SHA-256 output is always 32 bytes");
AnySignature::K256(sig)
}
AnySigningKey::P256(k) => {
let sig: p256::ecdsa::Signature = P256PrehashSigner::sign_prehash(k, prehash)
.expect("SHA-256 output is always 32 bytes");
let normalized = sig.normalize_s().unwrap_or(sig);
AnySignature::P256(normalized)
}
}
}
}
#[derive(Debug, Clone)]
pub enum AnySignature {
K256(k256::ecdsa::Signature),
P256(p256::ecdsa::Signature),
}
impl AnySignature {
pub fn to_jws_bytes(&self) -> [u8; 64] {
match self {
AnySignature::K256(s) => s.to_bytes().into(),
AnySignature::P256(s) => s.to_bytes().into(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AnySignatureError {
#[error("secp256k1 signature verification failed")]
K256(#[source] k256::ecdsa::Error),
#[error("P-256 signature verification failed")]
P256(#[source] p256::ecdsa::Error),
#[error("Signature and key use mismatched curves")]
CurveMismatch,
}
#[derive(Debug, Clone)]
pub struct ParsedMultikey {
pub verifying_key: AnyVerifyingKey,
}
#[derive(Debug, Error)]
pub enum IdentityError {
#[error("Invalid handle format")]
InvalidHandle,
#[error("Handle could not be resolved")]
HandleUnresolvable {
dns_error: Option<Box<IdentityError>>,
http_error: Option<Box<IdentityError>>,
},
#[error("DNS lookup failed")]
DnsLookupFailed {
#[source]
source: Box<IdentityError>,
},
#[error("DNS backend error")]
DnsBackend(#[from] hickory_resolver::ResolveError),
#[error("HTTP fallback for handle resolution failed")]
HandleHttpFallbackFailed {
#[source]
source: Box<IdentityError>,
},
#[error("Unsupported DID method: {method}")]
UnsupportedDidMethod { method: String },
#[error("DID resolution failed with status {status}")]
DidResolutionFailed {
status: u16,
body: String,
},
#[error("DNS record for {handle} has no did= entry")]
DnsNoDidRecord {
handle: String,
},
#[error("Invalid DID body: {body}")]
InvalidDidBody {
body: String,
},
#[error("DID document decode failed")]
DidDocumentDecodeFailed {
source_name: String,
source_bytes: Arc<[u8]>,
#[source]
cause: serde_json::Error,
},
#[error("Multikey decoding failed")]
MultikeyDecodeFailed {
#[source]
source: Box<IdentityError>,
},
#[error("Unsupported multibase encoding")]
UnsupportedMultibase(String),
#[error("Unsupported curve")]
UnsupportedCurve { codec_prefix: Vec<u8> },
#[error("Invalid multikey length")]
MultikeyLengthInvalid,
#[error("HTTP transport error")]
HttpTransport(#[from] reqwest::Error),
}
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>;
}
#[async_trait]
pub trait DnsResolver: Send + Sync {
async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError>;
}
pub struct RealHttpClient {
inner: reqwest::Client,
}
impl RealHttpClient {
pub fn new() -> Result<Self, IdentityError> {
let client = reqwest::Client::builder()
.use_rustls_tls()
.user_agent(APP_USER_AGENT)
.timeout(std::time::Duration::from_secs(10))
.build()?;
Ok(Self { inner: client })
}
pub fn from_client(client: reqwest::Client) -> Self {
Self { inner: client }
}
}
#[async_trait]
impl HttpClient for RealHttpClient {
async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
let response = self.inner.get(url.clone()).send().await?;
let status = response.status().as_u16();
let bytes = response.bytes().await?;
Ok((status, bytes.to_vec()))
}
}
pub struct RealDnsResolver {
inner: hickory_resolver::TokioResolver,
}
impl RealDnsResolver {
pub fn new() -> Self {
let resolver = hickory_resolver::Resolver::builder_tokio()
.expect("failed to build DNS resolver")
.build();
Self { inner: resolver }
}
}
impl Default for RealDnsResolver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DnsResolver for RealDnsResolver {
async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
let lookup = self.inner.txt_lookup(name).await?;
lookup
.iter()
.map(|record| {
let text = record
.iter()
.map(|data| {
String::from_utf8(data.to_vec()).unwrap_or_else(|_| {
tracing::debug!(
target = "atproto_devtool::identity",
"dropping non-UTF-8 TXT record data"
);
String::new()
})
})
.collect::<Vec<_>>()
.join("");
Ok(text)
})
.collect()
}
}
pub async fn resolve_handle(
handle: &str,
http: &dyn HttpClient,
dns: &dyn DnsResolver,
) -> Result<Did, IdentityError> {
if handle.is_empty()
|| handle.starts_with('.')
|| handle.ends_with('.')
|| !handle.is_ascii()
|| !handle.contains('.')
{
return Err(IdentityError::InvalidHandle);
}
tracing::debug!(
target = "atproto_devtool::identity",
handle = %handle,
"resolving handle"
);
let dns_name = format!("_atproto.{handle}");
let dns_error_opt = match dns.txt_lookup(&dns_name).await {
Ok(records) => {
for record in records {
let trimmed = record.trim();
if let Some(did_str) = trimmed.strip_prefix("did=") {
let did = Did(did_str.to_string());
tracing::debug!(
target = "atproto_devtool::identity",
did = %did,
"resolved handle via DNS"
);
return Ok(did);
}
}
Some(Box::new(IdentityError::DnsNoDidRecord {
handle: handle.to_string(),
}))
}
Err(e) => Some(Box::new(e)),
};
let url = format!("https://{handle}/.well-known/atproto-did");
let url = url
.parse::<Url>()
.map_err(|_| IdentityError::InvalidHandle)?;
let http_error_opt = match http.get_bytes(&url).await {
Ok((200, bytes)) => {
let did_str = String::from_utf8_lossy(&bytes).trim().to_string();
if !did_str.is_empty() && did_str.starts_with("did:") {
let did = Did(did_str);
tracing::debug!(
target = "atproto_devtool::identity",
did = %did,
"resolved handle via HTTPS"
);
return Ok(did);
} else {
Some(Box::new(IdentityError::HandleHttpFallbackFailed {
source: Box::new(IdentityError::InvalidDidBody { body: did_str }),
}))
}
}
Ok((status, bytes)) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
source: Box::new(IdentityError::DidResolutionFailed {
status,
body: String::from_utf8_lossy(&bytes).to_string(),
}),
})),
Err(e) => Some(Box::new(IdentityError::HandleHttpFallbackFailed {
source: Box::new(e),
})),
};
Err(IdentityError::HandleUnresolvable {
dns_error: dns_error_opt,
http_error: http_error_opt,
})
}
pub async fn resolve_did(
did: &Did,
http: &dyn HttpClient,
) -> Result<RawDidDocument, IdentityError> {
tracing::debug!(
target = "atproto_devtool::identity",
did = %did,
"resolving DID"
);
let (url, source_name) = match did.method() {
DidMethod::Plc => {
let did_str = &did.0;
let url_str = format!("https://plc.directory/{did_str}");
let url = url_str
.parse::<Url>()
.map_err(|_| IdentityError::DidResolutionFailed {
status: 400,
body: "Invalid DID format".to_string(),
})?;
(url.clone(), url.to_string())
}
DidMethod::Web => {
let rest = did.0.strip_prefix("did:web:").unwrap_or("");
let parts: Vec<&str> = rest.split(':').collect();
if parts.is_empty() {
return Err(IdentityError::DidResolutionFailed {
status: 400,
body: "Invalid did:web format".to_string(),
});
}
let host = parts[0];
let path_parts = &parts[1..];
let url_str = if path_parts.is_empty() {
format!("https://{host}/.well-known/did.json")
} else {
let path = path_parts
.iter()
.map(|p| percent_decode_str(p).unwrap_or_default())
.collect::<Vec<_>>()
.join("/");
format!("https://{host}/{path}/did.json")
};
let url = url_str
.parse::<Url>()
.map_err(|_| IdentityError::DidResolutionFailed {
status: 400,
body: "Invalid URL".to_string(),
})?;
(url.clone(), url.to_string())
}
DidMethod::Other => {
return Err(IdentityError::UnsupportedDidMethod {
method: did.0.clone(),
});
}
};
let (status, bytes) = http.get_bytes(&url).await?;
if status != 200 {
return Err(IdentityError::DidResolutionFailed {
status,
body: String::from_utf8_lossy(&bytes).to_string(),
});
}
tracing::debug!(
target = "atproto_devtool::identity",
bytes_len = bytes.len(),
"fetched DID document"
);
let parsed = serde_json::from_slice::<DidDocument>(&bytes).map_err(|e| {
IdentityError::DidDocumentDecodeFailed {
source_name: source_name.clone(),
source_bytes: Arc::from(bytes.clone()),
cause: e,
}
})?;
Ok(RawDidDocument {
parsed,
source_bytes: Arc::from(bytes),
source_name,
})
}
pub fn find_service<'a>(
doc: &'a DidDocument,
id_fragment: &str,
expected_type: &str,
) -> Option<&'a Service> {
let services = doc.service.as_ref()?;
for service in services {
let frag = service.id.rsplit_once('#').map(|(_, f)| f);
if frag == Some(id_fragment) && service.type_ == expected_type {
return Some(service);
}
}
None
}
pub fn parse_multikey(raw: &str) -> Result<ParsedMultikey, IdentityError> {
tracing::debug!(target = "atproto_devtool::identity", "parsing multikey");
let multibase_str = raw.strip_prefix("did:key:").unwrap_or(raw);
let (base, bytes) =
multibase::decode(multibase_str).map_err(|_| IdentityError::MultikeyDecodeFailed {
source: Box::new(IdentityError::UnsupportedMultibase(
"failed to decode multibase".to_string(),
)),
})?;
if base != multibase::Base::Base58Btc {
return Err(IdentityError::UnsupportedMultibase(
"multikey must use base58btc encoding".to_string(),
));
}
if bytes.len() < 2 {
return Err(IdentityError::MultikeyLengthInvalid);
}
let curve_bytes = [bytes[0], bytes[1]];
let rest = &bytes[2..];
match curve_bytes {
[0xe7, 0x01] => {
if rest.len() != 33 {
return Err(IdentityError::MultikeyLengthInvalid);
}
let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
IdentityError::MultikeyDecodeFailed {
source: Box::new(IdentityError::MultikeyLengthInvalid),
}
})?;
tracing::debug!(
target = "atproto_devtool::identity",
curve = "secp256k1",
"parsed multikey"
);
Ok(ParsedMultikey {
verifying_key: AnyVerifyingKey::K256(key),
})
}
[0x80, 0x24] => {
if rest.len() != 33 {
return Err(IdentityError::MultikeyLengthInvalid);
}
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(rest).map_err(|_| {
IdentityError::MultikeyDecodeFailed {
source: Box::new(IdentityError::MultikeyLengthInvalid),
}
})?;
tracing::debug!(
target = "atproto_devtool::identity",
curve = "p256",
"parsed multikey"
);
Ok(ParsedMultikey {
verifying_key: AnyVerifyingKey::P256(key),
})
}
_ => Err(IdentityError::UnsupportedCurve {
codec_prefix: curve_bytes.to_vec(),
}),
}
}
pub fn encode_multikey(key: &AnyVerifyingKey) -> String {
const SECP256K1_PUB: &[u8] = &[0xe7, 0x01];
const P256_PUB: &[u8] = &[0x80, 0x24];
let (prefix, compressed): (&[u8], Vec<u8>) = match key {
AnyVerifyingKey::K256(k) => {
let point = k.to_encoded_point(true);
(SECP256K1_PUB, point.as_bytes().to_vec())
}
AnyVerifyingKey::P256(k) => {
let point = k.to_encoded_point(true);
(P256_PUB, point.as_bytes().to_vec())
}
};
let mut buf = Vec::with_capacity(prefix.len() + compressed.len());
buf.extend_from_slice(prefix);
buf.extend_from_slice(&compressed);
multibase::encode(multibase::Base::Base58Btc, &buf)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlcHistoricKey {
pub key_id: String,
pub operation_cid: String,
pub introduced_at: String,
pub nullified: bool,
}
pub async fn plc_history_for_fragment(
did: &Did,
fragment: &str,
http: &dyn HttpClient,
) -> Result<Vec<PlcHistoricKey>, IdentityError> {
debug_assert!(
did.method() == DidMethod::Plc,
"plc_history_for_fragment called with non-plc DID: {did}"
);
if did.method() != DidMethod::Plc {
return Err(IdentityError::UnsupportedDidMethod {
method: format!("{:?}", did.method()),
});
}
let audit_url = format!("https://plc.directory/{did}/log/audit");
let url = Url::parse(&audit_url).map_err(|_| IdentityError::DidResolutionFailed {
status: 400,
body: "Invalid PLC audit URL".to_string(),
})?;
let (status, bytes) = http.get_bytes(&url).await?;
if status != 200 {
return Err(IdentityError::DidResolutionFailed {
status,
body: format!("PLC audit log fetch returned status {status}"),
});
}
let operations: Vec<serde_json::Value> =
serde_json::from_slice(&bytes).map_err(|cause| IdentityError::DidDocumentDecodeFailed {
source_name: "plc audit log".to_string(),
source_bytes: Arc::from(bytes.into_boxed_slice()),
cause,
})?;
let mut historic_keys: Vec<PlcHistoricKey> = Vec::new();
for op in operations {
let vm = match op
.get("operation")
.and_then(|o| o.get("verificationMethods"))
{
Some(vm) => vm,
None => continue,
};
if let Some(multikey_value) = vm.get(fragment) {
let multikey_str = match multikey_value.as_str() {
Some(s) => s.to_string(),
None => continue,
};
let operation_cid = op
.get("cid")
.and_then(|c| c.as_str())
.unwrap_or("unknown")
.to_string();
let introduced_at = op
.get("operation")
.and_then(|o| o.get("createdAt"))
.and_then(|c| c.as_str())
.unwrap_or("unknown")
.to_string();
let nullified = op
.get("nullified")
.and_then(|n| n.as_bool())
.unwrap_or(false);
if historic_keys.iter().any(|k| k.key_id == multikey_str) {
continue;
}
historic_keys.push(PlcHistoricKey {
key_id: multikey_str,
operation_cid,
introduced_at,
nullified,
});
}
}
Ok(historic_keys)
}
fn percent_decode_str(s: &str) -> Result<String, IdentityError> {
let decoded = percent_encoding::percent_decode_str(s)
.decode_utf8()
.map_err(|_| IdentityError::DidResolutionFailed {
status: 400,
body: "Invalid UTF-8 in percent-encoded path".to_string(),
})?;
Ok(decoded.to_string())
}
pub fn is_local_labeler_hostname(url: &Url) -> bool {
use url::Host;
let host = match url.host() {
Some(h) => h,
None => return false,
};
match host {
Host::Ipv4(addr) => addr.is_loopback() || addr.is_private(),
Host::Ipv6(addr) => addr.is_loopback(),
Host::Domain(domain) => {
let lower = domain.to_ascii_lowercase();
if lower == "localhost" {
return true;
}
if lower.ends_with(".local") {
return true;
}
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use k256::ecdsa::SigningKey as K256SigningKey;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use p256::ecdsa::SigningKey as P256SigningKey;
use sha2::Digest;
use std::collections::HashMap;
#[derive(Clone)]
enum Response {
Http(u16, Vec<u8>),
Transport(String),
}
struct FakeHttpClient {
responses: HashMap<String, Response>,
}
#[async_trait]
impl HttpClient for FakeHttpClient {
async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
match self.responses.get(url.as_str()).cloned() {
Some(Response::Http(status, body)) => Ok((status, body)),
Some(Response::Transport(message)) => {
Err(IdentityError::DidResolutionFailed {
status: 0,
body: format!("Transport error: {message}"),
})
}
None => Err(IdentityError::DidResolutionFailed {
status: 404,
body: "Not found".to_string(),
}),
}
}
}
struct FakeDnsResolver {
records: HashMap<String, Vec<String>>,
}
#[async_trait]
impl DnsResolver for FakeDnsResolver {
async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> {
self.records
.get(name)
.cloned()
.ok_or_else(|| IdentityError::DnsLookupFailed {
source: Box::new(IdentityError::InvalidHandle),
})
}
}
#[tokio::test]
async fn resolve_handle_via_dns() {
let mut records = HashMap::new();
records.insert(
"_atproto.alice.example".to_string(),
vec!["did=did:plc:abc123".to_string()],
);
let dns = FakeDnsResolver { records };
let http = FakeHttpClient {
responses: HashMap::new(),
};
let result = resolve_handle("alice.example", &http, &dns).await;
assert!(result.is_ok());
let did = result.unwrap();
assert_eq!(did.0, "did:plc:abc123");
}
#[tokio::test]
async fn resolve_handle_via_https_fallback() {
let dns = FakeDnsResolver {
records: HashMap::new(),
};
let mut responses = HashMap::new();
responses.insert(
"https://alice.example/.well-known/atproto-did".to_string(),
Response::Http(200, b"did:plc:abc123\n".to_vec()),
);
let http = FakeHttpClient { responses };
let result = resolve_handle("alice.example", &http, &dns).await;
assert!(result.is_ok());
let did = result.unwrap();
assert_eq!(did.0, "did:plc:abc123");
}
#[tokio::test]
async fn resolve_handle_both_paths_fail() {
let dns = FakeDnsResolver {
records: HashMap::new(),
};
let http = FakeHttpClient {
responses: HashMap::new(),
};
let result = resolve_handle("alice.example", &http, &dns).await;
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::HandleUnresolvable {
dns_error,
http_error,
} => {
assert!(dns_error.is_some());
assert!(http_error.is_some());
}
_ => panic!("Expected HandleUnresolvable error"),
}
}
#[tokio::test]
async fn resolve_did_plc_success() {
let plc_doc = include_bytes!("../../tests/fixtures/identity/plc_bsky_labeler.json");
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:plc:test-labeler".to_string(),
Response::Http(200, plc_doc.to_vec()),
);
let http = FakeHttpClient { responses };
let did = Did("did:plc:test-labeler".to_string());
let raw_doc = resolve_did(&did, &http).await.expect("resolve_did");
assert_eq!(raw_doc.parsed.id, "did:plc:test-labeler");
assert!(raw_doc.source_bytes.as_ref() == plc_doc);
assert_eq!(
raw_doc.source_name,
"https://plc.directory/did:plc:test-labeler"
);
let services = raw_doc.parsed.service.as_ref().expect("services");
assert!(
services.iter().any(|s| s.type_ == "AtprotoLabeler"),
"fixture must contain a labeler service"
);
assert!(
services
.iter()
.any(|s| s.type_ == "AtprotoPersonalDataServer"),
"fixture must contain a PDS service"
);
let vms = raw_doc
.parsed
.verification_method
.as_ref()
.expect("verificationMethod");
assert!(
vms.iter().any(|vm| vm.id == "#atproto"),
"fixture must contain a repo signing key"
);
assert!(
vms.iter().any(|vm| vm.id == "#atproto_label"),
"fixture must contain a label signing key"
);
}
#[tokio::test]
async fn resolve_did_web_success() {
let web_doc = include_bytes!("../../tests/fixtures/identity/web_example.json");
let mut responses = HashMap::new();
responses.insert(
"https://example.com/.well-known/did.json".to_string(),
Response::Http(200, web_doc.to_vec()),
);
let http = FakeHttpClient { responses };
let did = Did("did:web:example.com".to_string());
let result = resolve_did(&did, &http).await;
assert!(result.is_ok());
let raw_doc = result.unwrap();
assert_eq!(raw_doc.parsed.id, "did:web:example.com");
assert_eq!(
raw_doc.source_name,
"https://example.com/.well-known/did.json"
);
}
#[tokio::test]
async fn resolve_did_decode_failure_preserves_bytes() {
let bad_json = b"not valid json";
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:plc:bad".to_string(),
Response::Http(200, bad_json.to_vec()),
);
let http = FakeHttpClient { responses };
let did = Did("did:plc:bad".to_string());
let result = resolve_did(&did, &http).await;
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::DidDocumentDecodeFailed {
source_name: _,
source_bytes,
cause: _,
} => {
assert_eq!(source_bytes.as_ref(), bad_json);
}
_ => panic!("Expected DidDocumentDecodeFailed error"),
}
}
#[test]
fn find_service_matches_both_id_forms() {
let doc = DidDocument {
id: "did:plc:abc".to_string(),
also_known_as: None,
verification_method: None,
service: Some(vec![
Service {
id: "did:plc:abc#atproto_labeler".to_string(),
type_: "AtprotoLabeler".to_string(),
service_endpoint: "https://example.com/labeler".to_string(),
},
Service {
id: "#atproto_pds".to_string(),
type_: "AtprotoPersonalDataServer".to_string(),
service_endpoint: "https://example.com/pds".to_string(),
},
Service {
id: "#xatproto_labeler".to_string(),
type_: "OtherType".to_string(),
service_endpoint: "https://example.com/other".to_string(),
},
]),
};
let labeler = find_service(&doc, "atproto_labeler", "AtprotoLabeler");
assert!(labeler.is_some());
let labeler = labeler.unwrap();
assert_eq!(labeler.id, "did:plc:abc#atproto_labeler");
let pds = find_service(&doc, "atproto_pds", "AtprotoPersonalDataServer");
assert!(pds.is_some());
let pds = pds.unwrap();
assert_eq!(pds.id, "#atproto_pds");
let wrong = find_service(&doc, "atproto_labeler", "OtherType");
assert!(wrong.is_none());
}
#[test]
fn find_service_type_mismatch_returns_none() {
let doc = DidDocument {
id: "did:plc:abc".to_string(),
also_known_as: None,
verification_method: None,
service: Some(vec![Service {
id: "#atproto_labeler".to_string(),
type_: "AtprotoLabeler".to_string(),
service_endpoint: "https://example.com/labeler".to_string(),
}]),
};
let result = find_service(&doc, "atproto_labeler", "WrongType");
assert!(result.is_none());
}
#[test]
fn parse_multikey_k256() {
let multikey = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
let result = parse_multikey(multikey);
assert!(result.is_ok());
let parsed = result.unwrap();
match &parsed.verifying_key {
AnyVerifyingKey::K256(key) => {
let sec1_bytes = key.to_sec1_bytes();
assert_eq!(sec1_bytes.len(), 33); let expected_hex =
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
let actual_hex = sec1_bytes.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
assert_eq!(actual_hex, expected_hex);
}
_ => panic!("Expected K256 verifying key"),
}
}
#[test]
fn parse_multikey_p256() {
let multikey = include_str!("../../tests/fixtures/identity/multikey_p256.txt").trim();
let result = parse_multikey(multikey);
assert!(result.is_ok());
let parsed = result.unwrap();
match &parsed.verifying_key {
AnyVerifyingKey::P256(key) => {
let sec1_bytes = key.to_encoded_point(true).as_bytes().to_vec();
assert_eq!(sec1_bytes.len(), 33);
let expected_hex =
"026b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296";
let actual_hex = sec1_bytes.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
assert_eq!(actual_hex, expected_hex);
}
_ => panic!("Expected P256 verifying key"),
}
}
#[test]
fn parse_multikey_unsupported_curve() {
let mut unsupported_bytes = vec![0x01, 0x00];
unsupported_bytes.extend_from_slice(&[0; 33]); let multikey = multibase::encode(multibase::Base::Base58Btc, unsupported_bytes);
let result = parse_multikey(&multikey);
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::UnsupportedCurve { codec_prefix: _ } => {}
_ => panic!("Expected UnsupportedCurve error"),
}
}
#[test]
fn parse_multikey_not_base58btc() {
let mut key_bytes = vec![0xe7, 0x01];
key_bytes.extend_from_slice(&[0; 33]);
let hex_str = key_bytes.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
let multikey = format!("f{hex_str}");
let result = parse_multikey(&multikey);
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::UnsupportedMultibase(_) => {}
_ => panic!("Expected UnsupportedMultibase error"),
}
}
#[test]
fn parse_multikey_accepts_did_key_prefix() {
let bare = include_str!("../../tests/fixtures/identity/multikey_k256.txt").trim();
let did_key = format!("did:key:{bare}");
let from_bare = parse_multikey(bare).expect("bare multikey should parse");
let from_did_key = parse_multikey(&did_key).expect("did:key multikey should parse");
assert!(matches!(from_bare.verifying_key, AnyVerifyingKey::K256(_)));
assert!(matches!(
from_did_key.verifying_key,
AnyVerifyingKey::K256(_)
));
}
#[test]
fn parse_multikey_wrong_length() {
let mut wrong_len_bytes = vec![0x80, 0x24]; wrong_len_bytes.extend_from_slice(&[0; 10]);
let multikey = multibase::encode(multibase::Base::Base58Btc, &wrong_len_bytes);
let result = parse_multikey(&multikey);
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::MultikeyLengthInvalid => {
}
e => panic!("Expected MultikeyLengthInvalid, got {e:?}"),
}
}
#[test]
fn verify_prehash_k256_valid() {
let signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
let verifying_key = signing_key.verifying_key();
let prehash = *b"01234567890123456789012345678901";
let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
let any_key = AnyVerifyingKey::K256(*verifying_key);
let any_sig = AnySignature::K256(signature);
assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
}
#[test]
fn verify_prehash_p256_valid() {
let signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let verifying_key = signing_key.verifying_key();
let prehash = *b"01234567890123456789012345678901";
let signature = signing_key.sign_prehash(&prehash).expect("signing failed");
let any_key = AnyVerifyingKey::P256(*verifying_key);
let any_sig = AnySignature::P256(signature);
assert!(any_key.verify_prehash(&prehash, &any_sig).is_ok());
}
#[test]
fn verify_prehash_curve_mismatch() {
let k256_signing_key = K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
let k256_key = AnyVerifyingKey::K256(*k256_signing_key.verifying_key());
let p256_signing_key = P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let prehash = *b"01234567890123456789012345678901";
let p256_sig = p256_signing_key
.sign_prehash(&prehash)
.expect("signing failed");
let p256_any_sig = AnySignature::P256(p256_sig);
assert!(k256_key.verify_prehash(&prehash, &p256_any_sig).is_err());
let p256_signing_key_2 =
P256SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let p256_key = AnyVerifyingKey::P256(*p256_signing_key_2.verifying_key());
let k256_signing_key_2 =
K256SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
let k256_sig = k256_signing_key_2
.sign_prehash(&prehash)
.expect("signing failed");
let k256_any_sig = AnySignature::K256(k256_sig);
assert!(p256_key.verify_prehash(&prehash, &k256_any_sig).is_err());
}
#[tokio::test]
async fn plc_history_parses_rotation_fixture() {
let fixture_bytes =
include_bytes!("../../tests/fixtures/identity/plc_audit_log_with_rotation.json");
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:plc:test/log/audit".to_string(),
Response::Http(200, fixture_bytes.to_vec()),
);
let http = FakeHttpClient { responses };
let did = Did("did:plc:test".to_string());
let result = plc_history_for_fragment(&did, "atproto_label", &http)
.await
.expect("plc_history should succeed");
assert_eq!(result.len(), 2);
assert_eq!(
result[0].key_id,
"z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y"
);
assert!(!result[0].nullified);
assert_eq!(
result[1].key_id,
"z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"
);
assert!(!result[1].nullified);
}
#[tokio::test]
async fn plc_history_dedupes_repeated_key() {
let key = "did:key:zQ3shw6eSipD1cnrmmokVWvKCuE6Yc9j2jAjWJ9nWpuF4yQKV";
let log = serde_json::json!([
{"cid": "op3", "operation": {"verificationMethods": {"atproto_label": key}}},
{"cid": "op2", "operation": {"verificationMethods": {"atproto_label": key}}},
{"cid": "op1", "operation": {"verificationMethods": {"atproto_label": key}}},
]);
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:plc:dedupe/log/audit".to_string(),
Response::Http(200, serde_json::to_vec(&log).unwrap()),
);
let http = FakeHttpClient { responses };
let did = Did("did:plc:dedupe".to_string());
let result = plc_history_for_fragment(&did, "atproto_label", &http)
.await
.expect("plc_history should succeed");
assert_eq!(result.len(), 1);
assert_eq!(result[0].key_id, key);
assert_eq!(result[0].operation_cid, "op3");
}
#[tokio::test]
#[should_panic(expected = "plc_history_for_fragment called with non-plc DID")]
async fn plc_history_unsupported_method_errors() {
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:web:example.com/log/audit".to_string(),
Response::Http(200, b"[]".to_vec()),
);
let http = FakeHttpClient { responses };
let did = Did("did:web:example.com".to_string());
let _result = plc_history_for_fragment(&did, "atproto_label", &http).await;
}
#[tokio::test]
async fn plc_history_transport_error_propagates() {
let mut responses = HashMap::new();
responses.insert(
"https://plc.directory/did:plc:test/log/audit".to_string(),
Response::Transport("connection refused".to_string()),
);
let http = FakeHttpClient { responses };
let did = Did("did:plc:test".to_string());
let result = plc_history_for_fragment(&did, "atproto_label", &http).await;
assert!(result.is_err());
match result.unwrap_err() {
IdentityError::DidResolutionFailed { status, body } => {
assert_eq!(status, 0);
assert_eq!(body, "Transport error: connection refused");
}
e => panic!("Expected DidResolutionFailed with status 0, got {e:?}"),
}
}
#[test]
fn any_signing_key_k256_round_trip() {
let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
let vkey = key.verifying_key();
let msg = b"test message";
let sig = key.sign(msg);
assert!(vkey.verify_prehash(&[0u8; 32], &sig).is_err());
let sig2 = key.sign(msg);
let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
assert!(vkey.verify_prehash(&hash, &sig2).is_ok());
}
#[test]
fn any_signing_key_p256_round_trip() {
let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
let vkey = key.verifying_key();
let msg = b"test message";
let sig = key.sign(msg);
let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
assert!(vkey.verify_prehash(&hash, &sig).is_ok());
}
#[test]
fn any_signing_key_jwt_alg() {
let k256_key =
AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
let p256_key =
AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
assert_eq!(k256_key.jwt_alg(), "ES256K");
assert_eq!(p256_key.jwt_alg(), "ES256");
}
#[test]
fn any_signature_to_jws_bytes() {
let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
let msg = b"test";
let sig = key.sign(msg);
let jws_bytes = sig.to_jws_bytes();
assert_eq!(jws_bytes.len(), 64);
}
#[test]
fn any_signing_key_p256_signature_is_normalized() {
let key = AnySigningKey::P256(P256SigningKey::from_slice(&[3u8; 32]).expect("valid seed"));
let msg = b"test message for normalization";
let vkey = key.verifying_key();
let sig = key.sign(msg);
if let AnySignature::P256(sig_p256) = &sig {
assert!(
sig_p256.normalize_s().is_none(),
"signature should already be low-s (further normalization should return None)"
);
} else {
unreachable!("signing with P256 key must produce P256 signature");
}
use sha2::Digest as _;
let hash: [u8; 32] = sha2::Sha256::digest(msg).into();
assert!(
vkey.verify_prehash(&hash, &sig).is_ok(),
"P256 signature should verify after normalization"
);
let sig_bytes = sig.to_jws_bytes();
assert_eq!(
sig_bytes.len(),
64,
"P256 signature should be 64 bytes after JWS serialization"
);
}
#[test]
fn is_local_labeler_hostname_classifies_expected_hosts() {
let cases: &[(&str, bool)] = &[
("http://localhost/", true),
("https://LOCALHOST:8080/foo", true),
("http://127.0.0.1/", true),
("http://127.1.2.3/", true),
("http://[::1]/", true),
("http://mybox.local/", true),
("https://mybox.LOCAL:8443/", true),
("http://10.0.0.1/", true),
("http://172.16.0.1/", true),
("http://172.31.255.255/", true),
("http://192.168.1.100/", true),
("https://labeler.example.com/", false),
("http://8.8.8.8/", false),
("http://172.15.0.1/", false), ("http://172.32.0.1/", false), ("http://11.0.0.1/", false), ("http://172.17.1.1/", true), ];
for (url, expected) in cases {
let parsed = Url::parse(url).expect("test URLs are valid");
assert_eq!(
is_local_labeler_hostname(&parsed),
*expected,
"classification mismatch for {url}"
);
}
}
#[test]
fn encode_multikey_round_trip_k256() {
let signing_key = AnySigningKey::K256(k256::ecdsa::SigningKey::random(
&mut k256::elliptic_curve::rand_core::OsRng,
));
let original_verifying = signing_key.verifying_key();
let encoded = encode_multikey(&original_verifying);
assert!(
encoded.starts_with('z'),
"multikey should start with 'z' (base58btc)"
);
let parsed = parse_multikey(&encoded).expect("encoded multikey should parse");
match (&original_verifying, &parsed.verifying_key) {
(AnyVerifyingKey::K256(original), AnyVerifyingKey::K256(decoded)) => {
let orig_bytes = original.to_sec1_bytes();
let decoded_bytes = decoded.to_sec1_bytes();
assert_eq!(
orig_bytes, decoded_bytes,
"k256 keys should match after round-trip"
);
}
_ => panic!("Expected K256 keys"),
}
}
#[test]
fn encode_multikey_round_trip_p256() {
let signing_key = AnySigningKey::P256(p256::ecdsa::SigningKey::random(
&mut p256::elliptic_curve::rand_core::OsRng,
));
let original_verifying = signing_key.verifying_key();
let encoded = encode_multikey(&original_verifying);
assert!(
encoded.starts_with('z'),
"multikey should start with 'z' (base58btc)"
);
let parsed = parse_multikey(&encoded).expect("encoded multikey should parse");
match (&original_verifying, &parsed.verifying_key) {
(AnyVerifyingKey::P256(original), AnyVerifyingKey::P256(decoded)) => {
let orig_bytes = original.to_encoded_point(true).as_bytes().to_vec();
let decoded_bytes = decoded.to_encoded_point(true).as_bytes().to_vec();
assert_eq!(
orig_bytes, decoded_bytes,
"p256 keys should match after round-trip"
);
}
_ => panic!("Expected P256 keys"),
}
}
}