use anyhow::{bail, Context as _, Result};
use serde_json::Value;
use std::collections::HashSet;
const RESERVED_TLDS: [&str; 8] = [
"alt",
"arpa",
"example",
"internal",
"invalid",
"local",
"localhost",
"onion",
];
use crate::atproto::AT_URI_PREFIX;
pub fn normalize_handle(input: &str) -> Result<String> {
let handle = input.trim().to_ascii_lowercase();
if handle.is_empty() || handle.len() > 253 {
bail!("handle {input:?} has an invalid length");
}
let labels: Vec<&str> = handle.split('.').collect();
if labels.len() < 2 {
bail!("handle {input:?} must have at least two segments");
}
for label in &labels {
if label.is_empty() || label.len() > 63 {
bail!("handle {input:?} has an empty or over-long segment");
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
bail!("handle {input:?} has a segment with illegal characters");
}
if label.starts_with('-') || label.ends_with('-') {
bail!("handle {input:?} has a segment starting or ending with a hyphen");
}
}
let tld = labels[labels.len() - 1];
if tld.starts_with(|c: char| c.is_ascii_digit()) {
bail!("handle {input:?} has a TLD starting with a digit");
}
if RESERVED_TLDS.contains(&tld) {
bail!("handle {input:?} uses the reserved TLD .{tld}");
}
Ok(handle)
}
fn is_bare_did_web_host(host: &str) -> bool {
if host.is_empty() || host != host.to_ascii_lowercase() {
return false;
}
if host.bytes().any(|b| {
matches!(b, b':' | b'/' | b'@' | b'%' | b'?' | b'#' | b'\\') || b.is_ascii_whitespace()
}) {
return false;
}
if !host.contains('.') {
return false; }
if let Some(tld) = host.rsplit('.').next() {
if RESERVED_TLDS.contains(&tld) {
return false;
}
}
if let Some(tld) = host.rsplit('.').next() {
if tld.starts_with(|c: char| c.is_ascii_digit()) {
return false;
}
}
if host.len() > 253 || host.split('.').any(|label| label.len() > 63) {
return false;
}
host.split('.').all(|label| {
!label.is_empty()
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
&& !label.starts_with('-')
&& !label.ends_with('-')
})
}
pub fn is_atproto_did(did: &str) -> bool {
if let Some(ident) = did.strip_prefix("did:plc:") {
return ident.len() == 24
&& ident
.bytes()
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b));
}
if let Some(host) = did.strip_prefix("did:web:") {
return is_bare_did_web_host(host);
}
false
}
pub fn join_txt_chunks(chunks: &[&[u8]]) -> String {
let joined: Vec<u8> = chunks.iter().flat_map(|c| c.iter().copied()).collect();
String::from_utf8_lossy(&joined).into_owned()
}
pub fn did_from_txt_records(records: &[String]) -> Result<Option<String>> {
let mut candidates: Vec<&str> = records
.iter()
.filter_map(|r| r.strip_prefix("did="))
.collect();
candidates.sort_unstable();
candidates.dedup();
match candidates.len() {
0 => Ok(None),
1 => {
let did = candidates[0];
if !is_atproto_did(did) {
bail!("_atproto TXT record does not contain a usable DID: {did:?}");
}
Ok(Some(did.to_string()))
}
n => bail!("{n} `did=` TXT records present; resolution must fail rather than choose"),
}
}
pub fn did_from_well_known(body: &str) -> Result<String> {
let did = body.lines().next().unwrap_or_default().trim();
if !is_atproto_did(did) {
bail!("/.well-known/atproto-did did not contain a usable DID");
}
Ok(did.to_string())
}
pub fn did_document_url(did: &str, plc_directory: &str) -> Result<String> {
if let Some(ident) = did.strip_prefix("did:plc:") {
if !is_atproto_did(did) {
bail!("{did:?} is not a well-formed did:plc identifier ({ident:?})");
}
return Ok(format!("{}/{did}", plc_directory.trim_end_matches('/')));
}
if let Some(host) = did.strip_prefix("did:web:") {
if !is_bare_did_web_host(host) {
bail!(
"atproto did:web must be a bare, canonical hostname with no path, \
port, credentials or escapes, got {host:?}"
);
}
return Ok(format!("https://{host}/.well-known/did.json"));
}
bail!("unsupported DID method in {did:?}; only did:plc and did:web are resolvable")
}
pub fn validate_did_document(document: &Value, expected_did: &str) -> Result<()> {
let id = document
.get("id")
.and_then(Value::as_str)
.context("DID document has no `id`")?;
if id != expected_did {
bail!("DID document id {id:?} does not match the requested DID {expected_did:?}");
}
if let Some(services) = document.get("service").and_then(Value::as_array) {
let mut seen = HashSet::new();
for service in services {
if let Some(sid) = service.get("id").and_then(Value::as_str) {
let absolute = if let Some(fragment) = sid.strip_prefix('#') {
format!("{expected_did}#{fragment}")
} else {
sid.to_string()
};
if !seen.insert(absolute.clone()) {
bail!("DID document has duplicate service id {absolute:?}");
}
}
}
}
Ok(())
}
pub fn pds_endpoint(document: &Value, did: &str) -> Result<String> {
let services = document
.get("service")
.and_then(Value::as_array)
.context("DID document has no `service` array")?;
let absolute = format!("{did}#atproto_pds");
for service in services {
let id = service
.get("id")
.and_then(Value::as_str)
.unwrap_or_default();
let matches_id = id == "#atproto_pds" || id == absolute;
let matches_type =
service.get("type").and_then(Value::as_str) == Some("AtprotoPersonalDataServer");
if !matches_id || !matches_type {
continue;
}
let endpoint = service
.get("serviceEndpoint")
.and_then(Value::as_str)
.context("#atproto_pds serviceEndpoint is not a string")?;
let parsed = url::Url::parse(endpoint)
.with_context(|| format!("#atproto_pds serviceEndpoint {endpoint:?} is not a URL"))?;
if parsed.scheme() != "https" {
bail!("#atproto_pds serviceEndpoint must be https, got {endpoint:?}");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("#atproto_pds serviceEndpoint must not carry credentials, got {endpoint:?}");
}
return Ok(endpoint.to_string());
}
bail!("DID document declares no #atproto_pds service for {did}")
}
pub fn declared_handle(document: &Value) -> Option<String> {
document
.get("alsoKnownAs")?
.as_array()?
.iter()
.filter_map(Value::as_str)
.find_map(|entry| entry.strip_prefix(AT_URI_PREFIX))
.and_then(|handle| normalize_handle(handle).ok())
}
pub fn verify_handle_claim(document: &Value, handle: &str) -> Result<()> {
let wanted = normalize_handle(handle)?;
let claimed = declared_handle(document)
.context("DID document claims no handle; cannot verify bidirectionally")?;
if claimed != wanted {
bail!("DID document claims handle {claimed:?}, not {wanted:?}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
#[test]
fn handles_are_lowercased() {
assert_eq!(
normalize_handle("Alice.BSky.Social").unwrap(),
"alice.bsky.social"
);
assert_eq!(
normalize_handle(" bob.example.com ").unwrap(),
"bob.example.com"
);
}
#[test]
fn reserved_tlds_are_rejected() {
for handle in [
"alice.local",
"alice.localhost",
"alice.internal",
"alice.arpa",
"alice.invalid",
"alice.example",
"alice.alt",
"alice.onion",
"deep.sub.local",
] {
assert!(normalize_handle(handle).is_err(), "accepted {handle}");
}
}
#[test]
fn malformed_handles_are_rejected() {
for handle in [
"",
"alice", "alice.", ".alice.com", "alice..com", "-alice.com", "alice-.com", "alice.com-",
"alice_bob.com", "alice.123", "alice.1com",
"alice.4chan",
"alice.0x",
"al ice.com",
"alice.com/path",
"alice.com:443",
"https://alice.com",
] {
assert!(normalize_handle(handle).is_err(), "accepted {handle:?}");
}
}
#[test]
fn ordinary_handles_are_accepted() {
for handle in [
"alice.bsky.social",
"a.co",
"xn--80akhbyknj4f.com",
"very-long-label-with-hyphens.example.org",
] {
assert!(normalize_handle(handle).is_ok(), "rejected {handle}");
}
}
#[test]
fn txt_chunks_are_joined_into_one_record_value() {
let long = format!("did={DID}");
let (head, tail) = long.split_at(20);
assert_eq!(
join_txt_chunks(&[head.as_bytes(), tail.as_bytes()]),
long,
"chunks were not concatenated"
);
assert_eq!(join_txt_chunks(&[long.as_bytes()]), long);
assert_eq!(join_txt_chunks(&[]), "");
}
#[test]
fn a_did_split_across_txt_chunks_still_resolves() {
let long = format!("did={DID}");
let (head, tail) = long.split_at(20);
let joined = join_txt_chunks(&[head.as_bytes(), tail.as_bytes()]);
assert_eq!(
did_from_txt_records(&[joined]).unwrap().as_deref(),
Some(DID)
);
}
#[test]
fn a_single_did_record_resolves() {
let records = vec![format!("did={DID}")];
assert_eq!(
did_from_txt_records(&records).unwrap().as_deref(),
Some(DID)
);
}
#[test]
fn unrelated_txt_records_are_ignored() {
let records = vec![
"v=spf1 -all".to_string(),
format!("did={DID}"),
"google-site-verification=abc".to_string(),
];
assert_eq!(
did_from_txt_records(&records).unwrap().as_deref(),
Some(DID)
);
}
#[test]
fn multiple_did_records_fail_rather_than_picking_one() {
let records = vec![
format!("did={DID}"),
"did=did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
];
assert!(did_from_txt_records(&records).is_err());
}
#[test]
fn no_did_record_is_absent_not_an_error() {
let records = vec!["v=spf1 -all".to_string()];
assert_eq!(did_from_txt_records(&records).unwrap(), None);
}
#[test]
fn a_padded_did_value_is_invalid() {
let records = vec![format!("did= {DID}")];
assert!(did_from_txt_records(&records).is_err());
}
#[test]
fn a_non_did_value_is_rejected() {
for value in ["did=notadid", "did=", "did=did:unknown:xyz"] {
assert!(
did_from_txt_records(&[value.to_string()]).is_err(),
"accepted {value}"
);
}
}
#[test]
fn the_well_known_body_takes_the_first_line_trimmed() {
assert_eq!(did_from_well_known(&format!("{DID}\n")).unwrap(), DID);
assert_eq!(did_from_well_known(&format!(" {DID} ")).unwrap(), DID);
assert_eq!(
did_from_well_known(&format!("{DID}\nignored")).unwrap(),
DID
);
}
#[test]
fn a_well_known_body_that_is_not_a_did_is_rejected() {
for body in ["", "\n", "not a did", "<html>", "did:unknown:x"] {
assert!(did_from_well_known(body).is_err(), "accepted {body:?}");
}
}
#[test]
fn plc_dids_map_to_the_directory() {
assert_eq!(
did_document_url(DID, "https://plc.directory").unwrap(),
format!("https://plc.directory/{DID}")
);
}
#[test]
fn did_web_maps_to_the_hosts_well_known() {
assert_eq!(
did_document_url("did:web:example.com", "https://plc.directory").unwrap(),
"https://example.com/.well-known/did.json"
);
}
#[test]
fn did_web_with_a_path_or_port_is_rejected() {
for did in [
"did:web:example.com:path",
"did:web:example.com:8080",
"did:web:example.com%3A8080",
"did:web:example.com:path:to:doc",
] {
assert!(
did_document_url(did, "https://plc.directory").is_err(),
"accepted {did}"
);
assert!(!is_atproto_did(did), "is_atproto_did accepted {did}");
}
}
#[test]
fn did_web_host_confusion_and_path_injection_are_rejected() {
for did in [
"did:web:good.com@evil.com",
"did:web:evil.com/x",
"did:web:evil.com/.well-known/did.json#",
"did:web:%00",
"did:web:%2e%2e",
"did:web:ex ample.com",
"did:web:",
"did:web:.",
"did:web:-example.com",
"did:web:Example.com", ] {
assert!(!is_atproto_did(did), "is_atproto_did accepted {did:?}");
assert!(
did_document_url(did, "https://plc.directory").is_err(),
"built a URL for {did:?}"
);
}
}
#[test]
fn did_plc_uses_the_base32_sortable_alphabet() {
assert!(is_atproto_did(DID));
for did in [
"did:plc:aaaaaaaaaaaaaaaaaaaaaa01",
"did:plc:aaaaaaaaaaaaaaaaaaaaaa89",
"did:plc:AAAAAAAAAAAAAAAAAAAAAAAA",
"did:plc:tooshort",
"did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa",
] {
assert!(!is_atproto_did(did), "accepted {did}");
}
}
#[test]
fn unsupported_did_methods_are_rejected() {
for did in ["did:key:z6Mk", "did:example:123", "notadid", "", "did:"] {
assert!(
did_document_url(did, "https://plc.directory").is_err(),
"accepted {did}"
);
}
}
fn doc() -> serde_json::Value {
json!({
"id": DID,
"alsoKnownAs": ["at://alice.bsky.social"],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
})
}
#[test]
fn the_document_id_must_match_the_did_requested() {
let mut d = doc();
d["id"] = json!("did:plc:someoneelse00000000000");
assert!(validate_did_document(&d, DID).is_err());
assert!(validate_did_document(&doc(), DID).is_ok());
}
#[test]
fn a_document_without_an_id_is_rejected() {
let mut d = doc();
d.as_object_mut().unwrap().remove("id");
assert!(validate_did_document(&d, DID).is_err());
}
#[test]
fn duplicate_service_ids_are_rejected() {
let mut d = doc();
d["service"] = json!([
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://a.example"},
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://b.example"}
]);
assert!(validate_did_document(&d, DID).is_err());
}
#[test]
fn the_pds_endpoint_is_extracted() {
assert_eq!(
pds_endpoint(&doc(), DID).unwrap(),
"https://pds.example.com"
);
}
#[test]
fn an_absolute_service_id_is_accepted() {
let mut d = doc();
d["service"][0]["id"] = json!(format!("{DID}#atproto_pds"));
assert_eq!(pds_endpoint(&d, DID).unwrap(), "https://pds.example.com");
}
#[test]
fn a_foreign_service_id_ending_in_atproto_pds_does_not_win() {
let mut d = doc();
d["service"] = json!([
{"id": "urn:evil#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://attacker.example"},
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://real-pds.example"}
]);
assert_eq!(
pds_endpoint(&d, DID).unwrap(),
"https://real-pds.example",
"a decoy service id steered the PDS"
);
d["service"] = json!([
{"id": "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://attacker.example"},
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://real-pds.example"}
]);
assert_eq!(pds_endpoint(&d, DID).unwrap(), "https://real-pds.example");
}
#[test]
fn the_relative_and_absolute_spellings_count_as_one_service() {
let mut d = doc();
d["service"] = json!([
{"id": format!("{DID}#atproto_pds"), "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://attacker.example"},
{"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://real-pds.example"}
]);
assert!(
validate_did_document(&d, DID).is_err(),
"two spellings of the same service id were not seen as duplicates"
);
}
#[test]
fn a_plaintext_http_pds_is_rejected_including_on_loopback() {
let mut d = doc();
for endpoint in [
"http://pds.attacker.example",
"http://10.0.0.5",
"http://localhost:2583",
"http://127.0.0.1:2583",
] {
d["service"][0]["serviceEndpoint"] = json!(endpoint);
assert!(pds_endpoint(&d, DID).is_err(), "accepted {endpoint}");
}
}
#[test]
fn a_pds_endpoint_carrying_credentials_is_refused() {
let mut d = doc();
for endpoint in [
"https://good.example@attacker.example",
"https://u:p@attacker.example/x",
] {
d["service"][0]["serviceEndpoint"] = json!(endpoint);
assert!(pds_endpoint(&d, DID).is_err(), "accepted {endpoint}");
}
}
#[test]
fn did_web_hosts_obey_the_reserved_tld_and_ip_policy() {
for did in [
"did:web:169.254.169.254", "did:web:127.0.0.1",
"did:web:10.0.0.5",
"did:web:pds.internal",
"did:web:printer.local",
"did:web:something.localhost",
"did:web:site.onion",
] {
assert!(!is_atproto_did(did), "accepted {did}");
}
for did in ["did:web:127.1", "did:web:0177.0.0.1", "did:web:0x7f.0.0.1"] {
assert!(!is_atproto_did(did), "accepted {did}");
}
assert!(!is_atproto_did(&format!("did:web:{}.com", "a".repeat(300))));
assert!(
!is_atproto_did(&format!("did:web:{}.com", "a".repeat(64))),
"a 64-byte label exceeds the DNS limit"
);
assert!(is_atproto_did(&format!("did:web:{}.com", "a".repeat(63))));
assert!(!is_atproto_did("did:web:foo.1com"));
assert!(is_atproto_did("did:web:pds.example.com"));
}
#[test]
fn a_service_failing_any_condition_is_not_the_pds() {
let cases = [
json!({"id": "#atproto_pds", "type": "SomethingElse", "serviceEndpoint": "https://a.example"}),
json!({"id": "#other", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://a.example"}),
json!({"id": "urn:evil#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://a.example"}),
json!({"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "not a url"}),
json!({"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": ["https://a.example"]}),
json!({"id": "#atproto_pds", "type": "AtprotoPersonalDataServer"}),
];
for svc in cases {
let mut d = doc();
d["service"] = json!([svc.clone()]);
assert!(pds_endpoint(&d, DID).is_err(), "accepted {svc}");
}
}
#[test]
fn a_document_with_no_services_has_no_pds() {
let mut d = doc();
d["service"] = json!([]);
assert!(pds_endpoint(&d, DID).is_err());
d.as_object_mut().unwrap().remove("service");
assert!(pds_endpoint(&d, DID).is_err());
}
#[test]
fn the_declared_handle_is_the_first_at_uri_entry_normalized() {
let mut d = doc();
d["alsoKnownAs"] = json!(["at://Alice.BSky.Social"]);
assert_eq!(declared_handle(&d).as_deref(), Some("alice.bsky.social"));
}
#[test]
fn only_the_first_at_uri_entry_counts() {
let mut d = doc();
d["alsoKnownAs"] = json!(["at://attacker.com", "at://victim.com"]);
assert_eq!(declared_handle(&d).as_deref(), Some("attacker.com"));
assert!(verify_handle_claim(&d, "victim.com").is_err());
assert!(verify_handle_claim(&d, "attacker.com").is_ok());
}
#[test]
fn non_at_uri_entries_are_skipped() {
let mut d = doc();
d["alsoKnownAs"] = json!(["https://alice.example", "at://alice.bsky.social"]);
assert_eq!(declared_handle(&d).as_deref(), Some("alice.bsky.social"));
}
#[test]
fn a_document_claiming_no_handle_fails_verification() {
let mut d = doc();
d["alsoKnownAs"] = json!([]);
assert!(verify_handle_claim(&d, "alice.bsky.social").is_err());
d.as_object_mut().unwrap().remove("alsoKnownAs");
assert!(verify_handle_claim(&d, "alice.bsky.social").is_err());
}
#[test]
fn a_malformed_claimed_handle_fails_verification() {
for claim in ["at://", "at://not a handle", "at://alice.local"] {
let mut d = doc();
d["alsoKnownAs"] = json!([claim]);
assert!(
verify_handle_claim(&d, "alice.bsky.social").is_err(),
"accepted claim {claim}"
);
}
}
#[test]
fn a_malformed_first_claim_does_not_fall_through_to_the_second() {
for bad_first in ["at://", "at://not a handle", "at://alice.local"] {
let mut d = doc();
d["alsoKnownAs"] = json!([bad_first, "at://victim.com"]);
assert_eq!(
declared_handle(&d),
None,
"a malformed first claim ({bad_first}) was skipped and the second was taken"
);
assert!(
verify_handle_claim(&d, "victim.com").is_err(),
"({bad_first}) the second entry verified as the account's handle"
);
}
}
#[test]
fn verification_is_case_insensitive_on_both_sides() {
let mut d = doc();
d["alsoKnownAs"] = json!(["at://Alice.BSky.Social"]);
assert!(verify_handle_claim(&d, "ALICE.bsky.SOCIAL").is_ok());
}
}