pub mod overrides;
pub use overrides::{overrides_or_fail, EndpointOverrides, OverrideError};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DidDocument {
#[serde(skip_serializing_if = "Option::is_none", rename = "@context")]
pub context: Option<Vec<String>>,
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub also_known_as: Option<Vec<String>>,
#[serde(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(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VerificationMethod {
pub id: String,
pub r#type: String,
pub controller: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key_multibase: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Service {
pub id: String,
pub r#type: String,
pub service_endpoint: String,
}
impl DidDocument {
pub fn get_pds_endpoint(&self) -> Option<String> {
self.get_service_endpoint("#atproto_pds", "AtprotoPersonalDataServer")
}
pub fn get_service_endpoint(&self, id: &str, r#type: &str) -> Option<String> {
let full_id = format!("{}{id}", self.id);
self.service
.as_ref()?
.iter()
.find(|s| (s.id == id || s.id == full_id) && s.r#type == r#type)
.map(|s| s.service_endpoint.clone())
.filter(|s| is_http_url(s))
}
pub fn get_signing_key(&self) -> Option<&VerificationMethod> {
let full_id = format!("{}#atproto", self.id);
self.verification_method.as_ref()?.iter().find(|m| m.id == "#atproto" || m.id == full_id)
}
}
fn is_http_url(s: &str) -> bool {
let Some(rest) = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")) else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let host = authority.rsplit('@').next().unwrap_or_default();
let host = host.split(':').next().unwrap_or_default();
!host.is_empty()
}
pub fn is_did(s: &str) -> bool {
if s.len() > 2048 {
return false;
}
let Some(rest) = s.strip_prefix("did:") else { return false };
let Some((method, id)) = rest.split_once(':') else { return false };
!method.is_empty()
&& method.bytes().all(|b| b.is_ascii_lowercase())
&& !id.is_empty()
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b"._:%-".contains(&b))
&& !id.ends_with([':', '%'])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DidResolveError {
NotFound,
Unavailable,
NoAtprotoKey,
BadKey,
}
impl std::fmt::Display for DidResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
DidResolveError::NotFound => "no such DID document",
DidResolveError::Unavailable => "the DID directory did not answer",
DidResolveError::NoAtprotoKey => "the DID document has no #atproto key",
DidResolveError::BadKey => "the DID document's #atproto key is unusable",
})
}
}
impl std::error::Error for DidResolveError {}
impl From<crate::service_jwt::ServiceJwtError> for DidResolveError {
fn from(e: crate::service_jwt::ServiceJwtError) -> Self {
match e {
crate::service_jwt::ServiceJwtError::BadKey => DidResolveError::BadKey,
_ => DidResolveError::NoAtprotoKey,
}
}
}
pub fn handle_from_aka(aka: Option<&[String]>) -> Option<String> {
aka?.iter().find_map(|a| a.strip_prefix("at://")).map(str::to_owned)
}
pub fn verified_handle(did: &str, claimed: &str, resolved: Option<&str>) -> Option<String> {
match resolved {
Some(r) if r == did => Some(claimed.to_owned()),
#[cfg(feature = "net")]
Some(r) => {
tracing::warn!(did, claimed_handle = %claimed, resolved_did = %r, "alsoKnownAs handle failed bidirectional verification");
None
}
_ => None,
}
}
#[cfg(feature = "net")]
mod resolver;
#[cfg(feature = "net")]
pub use resolver::{CommonDidKeyResolver, DidKeyResolver, InMemoryDidKeyResolver};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dids_are_checked_for_syntax() {
for ok in ["did:plc:ewvi7nxzyoun6zhxrhs64oiz", "did:web:example.com", "did:web:localhost%3A3000", "did:x:a:b"] {
assert!(is_did(ok), "{ok}");
}
for bad in ["", "did:", "did:plc", "did:plc:", "did:PLC:abc", "plc:abc", "did:plc:abc:", "did:plc:a b", "did:plc:abc%"] {
assert!(!is_did(bad), "{bad}");
}
assert!(!is_did(&format!("did:plc:{}", "a".repeat(2048))));
}
#[test]
fn a_document_names_its_key_and_pds() {
let doc: DidDocument = serde_json::from_value(serde_json::json!({
"id": "did:plc:x",
"verificationMethod": [{"id": "did:plc:x#atproto", "type": "Multikey", "controller": "did:plc:x", "publicKeyMultibase": "zQ3s"}],
"service": [
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://pds.example"},
{"id": "#other", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://wrong.example"},
],
}))
.unwrap();
assert_eq!(doc.get_signing_key().and_then(|m| m.public_key_multibase.as_deref()), Some("zQ3s"));
assert_eq!(doc.get_pds_endpoint().as_deref(), Some("https://pds.example"));
let mut bad = doc.clone();
bad.service.as_mut().unwrap()[0].service_endpoint = "ftp://pds.example".into();
assert_eq!(bad.get_pds_endpoint(), None, "only an http(s) URL with a host");
bad.service.as_mut().unwrap()[0].service_endpoint = "https://".into();
assert_eq!(bad.get_pds_endpoint(), None);
}
#[test]
fn handle_from_aka_takes_the_first_at_uri_host() {
let aka = vec!["https://web.example/profile".to_string(), "at://alice.example".to_string()];
assert_eq!(handle_from_aka(Some(&aka)), Some("alice.example".to_string()));
assert_eq!(handle_from_aka(Some(&["https://web.example".to_string()])), None);
assert_eq!(handle_from_aka(None), None);
assert_eq!(handle_from_aka(Some(&[])), None);
}
#[test]
fn verified_handle_requires_a_bidirectional_did_match() {
assert_eq!(verified_handle("did:plc:alice", "alice.example", Some("did:plc:alice")), Some("alice.example".to_string()));
assert_eq!(verified_handle("did:plc:attacker", "lucian.scenius.makewith.you", Some("did:plc:lucian")), None);
assert_eq!(verified_handle("did:plc:alice", "alice.example", None), None);
}
}