This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-atproto / src / resolve.rs
20 kB 586 lines
1use std::borrow::Cow; 2 3use knot_runtime::PublicKeyBytes; 4use knot_types::crypto::{KeyCodec, PublicKey as CryptoKey}; 5use knot_types::did_doc::DidDocument; 6use knot_types::{AccountDid, Handle, HttpStatus, RepoDid}; 7use url::{Host, Url}; 8 9const LEGACY_K256_KIND: &str = "EcdsaSecp256k1VerificationKey2019"; 10const LEGACY_P256_KIND: &str = "EcdsaSecp256r1VerificationKey2019"; 11 12#[derive(Debug, Clone, thiserror::Error)] 13pub enum ResolveError { 14 #[error("unsupported DID method in {value:?}")] 15 UnsupportedMethod { value: String }, 16 #[error("DID {value:?} doesn't form a resolvable document location")] 17 Unresolvable { value: String }, 18 #[error("DID document fetch returned HTTP {status}")] 19 Status { status: HttpStatus }, 20 #[error("network failure resolving DID document: {0}")] 21 Network(#[from] knot_runtime::NetworkError), 22 #[error("DID document isn't valid JSON: {0}")] 23 Malformed(String), 24 #[error("DID document for {requested:?} claims to describe {document:?}")] 25 IdMismatch { requested: String, document: String }, 26 #[error("refusing to resolve over non-https endpoint: {url}")] 27 InsecureScheme { url: String }, 28 #[error("refusing to resolve non-public address {host}")] 29 BlockedHost { host: String }, 30 #[error("DID document declares no atproto signing key")] 31 MissingSigningKey, 32 #[error("DID document signing key is unusable: {0}")] 33 BadSigningKey(String), 34 #[error("DID document declares no atproto_pds service endpoint")] 35 MissingPds, 36 #[error("atproto_pds endpoint {value:?} isn't valid URL")] 37 BadPds { value: String }, 38 #[error("PLC directory {value:?} isn't valid http(s) base URL")] 39 BadPlcDirectory { value: String }, 40 #[error("identity {did} recently failed to resolve and is negatively cached")] 41 RecentlyFailed { did: AccountDid }, 42 #[error("did:web document for {did} doesn't publish expected signing key")] 43 ExpectedKeyAbsent { did: RepoDid }, 44 #[error("handle {handle} has no atproto DNS or well-known record")] 45 HandleUnresolvable { handle: Handle }, 46 #[error("handle {handle} resolves to more than one distinct DID")] 47 HandleAmbiguous { handle: Handle }, 48 #[error("handle {handle} points at {value:?}, which isn't a valid DID")] 49 HandleForwardMalformed { handle: Handle, value: String }, 50 #[error("handle {handle} resolved to {resolved} but that document claims {claimed:?}")] 51 HandleMismatch { 52 handle: Handle, 53 resolved: AccountDid, 54 claimed: Option<Handle>, 55 }, 56 #[error("handle {handle} recently failed to resolve and is negatively cached")] 57 HandleRecentlyFailed { handle: Handle }, 58} 59 60impl ResolveError { 61 pub fn is_transient(&self) -> bool { 62 match self { 63 ResolveError::Network(_) => true, 64 ResolveError::Status { status } => status.is_transient(), 65 _ => false, 66 } 67 } 68} 69 70#[derive(Debug, Clone)] 71pub struct PdsEndpoint(Url); 72 73impl PdsEndpoint { 74 pub fn new(url: Url) -> Result<Self, ResolveError> { 75 match http_base(&url) { 76 true => Ok(Self(url)), 77 false => Err(ResolveError::BadPds { 78 value: url.as_str().to_string(), 79 }), 80 } 81 } 82 83 pub fn url(&self) -> &Url { 84 &self.0 85 } 86} 87 88#[derive(Debug, Clone)] 89pub struct PlcDirectory(Url); 90 91impl PlcDirectory { 92 pub fn new(url: Url) -> Result<Self, ResolveError> { 93 match http_base(&url) { 94 true => Ok(Self(url)), 95 false => Err(ResolveError::BadPlcDirectory { 96 value: url.as_str().to_string(), 97 }), 98 } 99 } 100} 101 102fn http_base(url: &Url) -> bool { 103 matches!(url.scheme(), "http" | "https") 104 && url.has_host() 105 && url.username().is_empty() 106 && url.password().is_none() 107 && url.query().is_none() 108 && url.fragment().is_none() 109} 110 111#[derive(Debug, Clone)] 112pub struct Identity { 113 pub did: AccountDid, 114 pub handles: Vec<Handle>, 115 pub signing_key: CryptoKey<'static>, 116 pub pds: PdsEndpoint, 117} 118 119impl Identity { 120 pub fn primary_handle(&self) -> Option<&Handle> { 121 self.handles.first() 122 } 123 124 pub fn claims_handle(&self, handle: &Handle) -> bool { 125 self.handles.iter().any(|known| known == handle) 126 } 127} 128 129pub(crate) fn document_url( 130 did: &AccountDid, 131 plc_directory: &PlcDirectory, 132) -> Result<Url, ResolveError> { 133 let value = did.as_str(); 134 if value.strip_prefix("did:plc:").is_some() { 135 let base = plc_directory.0.as_str().trim_end_matches('/'); 136 Url::parse(&format!("{base}/{value}")).map_err(|_| ResolveError::Unresolvable { 137 value: value.to_string(), 138 }) 139 } else if let Some(rest) = value.strip_prefix("did:web:") { 140 web_document_url(rest).ok_or(ResolveError::Unresolvable { 141 value: value.to_string(), 142 }) 143 } else { 144 Err(ResolveError::UnsupportedMethod { 145 value: value.to_string(), 146 }) 147 } 148} 149 150pub(crate) fn guard_fetch_url(url: &Url) -> Result<(), ResolveError> { 151 if url.scheme() != "https" { 152 return Err(ResolveError::InsecureScheme { 153 url: url.as_str().to_string(), 154 }); 155 } 156 let blocked = match url.host() { 157 Some(Host::Ipv4(ip)) => knot_runtime::is_blocked_ip(ip.into()).then(|| ip.to_string()), 158 Some(Host::Ipv6(ip)) => knot_runtime::is_blocked_ip(ip.into()).then(|| ip.to_string()), 159 _ => None, 160 }; 161 match blocked { 162 Some(host) => Err(ResolveError::BlockedHost { host }), 163 None => Ok(()), 164 } 165} 166 167fn web_document_url(rest: &str) -> Option<Url> { 168 let mut segments = rest.split(':'); 169 let authority = segments.next().filter(|head| !head.is_empty())?; 170 let host = authority.replace("%3A", ":").replace("%3a", ":"); 171 let path: Vec<&str> = segments.collect(); 172 let tail = if path.is_empty() { 173 ".well-known/did.json".to_string() 174 } else if path.iter().any(|segment| segment.is_empty()) { 175 return None; 176 } else { 177 format!("{}/did.json", path.join("/")) 178 }; 179 Url::parse(&format!("https://{host}/{tail}")).ok() 180} 181 182pub(crate) fn identity_from_document( 183 did: &AccountDid, 184 body: &[u8], 185) -> Result<Identity, ResolveError> { 186 let document: DidDocument = 187 serde_json::from_slice(body).map_err(|error| ResolveError::Malformed(error.to_string()))?; 188 if AccountDid::new(document.id.as_str()).ok().as_ref() != Some(did) { 189 return Err(ResolveError::IdMismatch { 190 requested: did.as_str().to_string(), 191 document: document.id.as_str().to_string(), 192 }); 193 } 194 let signing_key = atproto_signing_key(&document)?; 195 let pds_endpoint = document.pds_endpoint().ok_or(ResolveError::MissingPds)?; 196 let pds = Url::parse(pds_endpoint.as_str()) 197 .map_err(|_| ResolveError::BadPds { 198 value: pds_endpoint.as_str().to_string(), 199 }) 200 .and_then(PdsEndpoint::new)?; 201 let handles = document 202 .handles() 203 .iter() 204 .filter_map(|found| Handle::new_owned(found.as_str()).ok()) 205 .collect(); 206 Ok(Identity { 207 did: did.clone(), 208 handles, 209 signing_key, 210 pds, 211 }) 212} 213 214pub(crate) fn web_document_url_for(did: &RepoDid) -> Result<Url, ResolveError> { 215 let rest = 216 did.as_str() 217 .strip_prefix("did:web:") 218 .ok_or_else(|| ResolveError::UnsupportedMethod { 219 value: did.as_str().to_string(), 220 })?; 221 web_document_url(rest).ok_or_else(|| ResolveError::Unresolvable { 222 value: did.as_str().to_string(), 223 }) 224} 225 226pub(crate) fn document_publishes_key( 227 did: &RepoDid, 228 body: &[u8], 229 expected: &PublicKeyBytes, 230) -> Result<(), ResolveError> { 231 let document: DidDocument = 232 serde_json::from_slice(body).map_err(|error| ResolveError::Malformed(error.to_string()))?; 233 if document.id.as_str() != did.as_str() { 234 return Err(ResolveError::IdMismatch { 235 requested: did.as_str().to_string(), 236 document: document.id.as_str().to_string(), 237 }); 238 } 239 let methods = document 240 .verification_method 241 .as_ref() 242 .ok_or(ResolveError::MissingSigningKey)?; 243 let published = methods 244 .iter() 245 .filter_map(|method| method_key(method).ok()) 246 .any(|key| { 247 matches!(key.codec, KeyCodec::Secp256k1) && key.bytes.as_ref() == expected.as_bytes() 248 }); 249 if published { 250 Ok(()) 251 } else { 252 Err(ResolveError::ExpectedKeyAbsent { did: did.clone() }) 253 } 254} 255 256fn supported_kind(kind: &str) -> bool { 257 matches!(kind, "Multikey" | LEGACY_K256_KIND | LEGACY_P256_KIND) 258} 259 260fn method_key( 261 method: &knot_types::did_doc::VerificationMethod<knot_types::DefaultStr>, 262) -> Result<CryptoKey<'static>, String> { 263 let multibase = method 264 .public_key_multibase 265 .as_ref() 266 .ok_or_else(|| "verification method lacks publicKeyMultibase".to_string())? 267 .as_ref(); 268 match method.r#type.as_ref() { 269 "Multikey" => CryptoKey::decode_owned(multibase).map_err(|error| error.to_string()), 270 LEGACY_K256_KIND => legacy_key(KeyCodec::Secp256k1, multibase), 271 LEGACY_P256_KIND => legacy_key(KeyCodec::P256, multibase), 272 other => Err(format!("unsupported verification method type {other:?}")), 273 } 274} 275 276fn legacy_key(codec: KeyCodec, multibase: &str) -> Result<CryptoKey<'static>, String> { 277 let encoded = multibase 278 .strip_prefix('z') 279 .ok_or_else(|| format!("legacy key {multibase:?} isn't base58btc multibase"))?; 280 let bytes = bs58::decode(encoded) 281 .into_vec() 282 .map_err(|error| error.to_string())?; 283 Ok(CryptoKey { 284 codec, 285 bytes: Cow::Owned(bytes), 286 }) 287} 288 289fn atproto_signing_key(document: &DidDocument) -> Result<CryptoKey<'static>, ResolveError> { 290 let method = document 291 .verification_method 292 .as_ref() 293 .and_then(|methods| { 294 methods.iter().find(|method| { 295 let id: &str = method.id.as_ref(); 296 id.ends_with("#atproto") 297 && supported_kind(method.r#type.as_ref()) 298 && method.public_key_multibase.is_some() 299 }) 300 }) 301 .ok_or(ResolveError::MissingSigningKey)?; 302 method_key(method).map_err(ResolveError::BadSigningKey) 303} 304 305#[cfg(test)] 306mod tests { 307 use super::*; 308 use crate::test_support::*; 309 use bytes::Bytes; 310 311 struct UrlCase { 312 did: &'static str, 313 expect: fn(&Result<Url, ResolveError>) -> bool, 314 } 315 316 const URL_CASES: &[UrlCase] = &[ 317 UrlCase { 318 did: "did:plc:squid", 319 expect: |r| matches!(r, Ok(u) if u.as_str() == "https://plc.directory/did:plc:squid"), 320 }, 321 UrlCase { 322 did: "did:web:nel.pet", 323 expect: |r| matches!(r, Ok(u) if u.as_str() == "https://nel.pet/.well-known/did.json"), 324 }, 325 UrlCase { 326 did: "did:web:nel.pet:repos:squid", 327 expect: |r| matches!(r, Ok(u) if u.as_str() == "https://nel.pet/repos/squid/did.json"), 328 }, 329 UrlCase { 330 did: "did:web:nel.pet%3A8443", 331 expect: |r| matches!(r, Ok(u) if u.as_str() == "https://nel.pet:8443/.well-known/did.json"), 332 }, 333 UrlCase { 334 did: "did:key:zabc", 335 expect: |r| matches!(r, Err(ResolveError::UnsupportedMethod { .. })), 336 }, 337 ]; 338 339 #[test] 340 fn document_url_maps_each_did_method_to_its_document_location() { 341 URL_CASES.iter().for_each(|case| { 342 let result = document_url(&did(case.did), &plc()); 343 assert!((case.expect)(&result), "case {:?} got {result:?}", case.did); 344 }); 345 } 346 347 fn body(value: serde_json::Value) -> Bytes { 348 Bytes::from(serde_json::to_vec(&value).unwrap()) 349 } 350 351 fn sample_key() -> String { 352 knot_types::crypto::multikey(0xe7, &sec1(&signer(5))) 353 } 354 355 fn legacy_document(kind: &str, multibase: &str) -> Bytes { 356 body(serde_json::json!({ 357 "id": SQUID, 358 "alsoKnownAs": ["at://nel.pet"], 359 "verificationMethod": [{ 360 "id": format!("{SQUID}#atproto"), 361 "type": kind, 362 "controller": SQUID, 363 "publicKeyMultibase": multibase 364 }], 365 "service": [{ 366 "id": "#atproto_pds", 367 "type": "AtprotoPersonalDataServer", 368 "serviceEndpoint": "https://pds.oyster.cafe" 369 }] 370 })) 371 } 372 373 #[test] 374 fn a_complete_document_yields_a_full_identity() { 375 let body = did_doc(DocSpec { 376 id: SQUID, 377 signing: &signer(5), 378 handle: "nel.pet", 379 pds: "https://pds.oyster.cafe", 380 method: MethodKind::Multikey, 381 }); 382 let identity = identity_from_document(&did(SQUID), &body).unwrap(); 383 assert_eq!(identity.pds.url().as_str(), "https://pds.oyster.cafe/"); 384 assert_eq!(identity.primary_handle().unwrap().as_str(), "nel.pet"); 385 } 386 387 #[test] 388 fn the_atproto_verification_method_wins_over_a_decoy_first_key() { 389 let decoy = sec1(&signer(3)); 390 let real = sec1(&signer(5)); 391 let document = body(serde_json::json!({ 392 "id": SQUID, 393 "alsoKnownAs": ["at://nel.pet"], 394 "verificationMethod": [ 395 { 396 "id": format!("{SQUID}#extra"), 397 "type": "Multikey", 398 "controller": SQUID, 399 "publicKeyMultibase": knot_types::crypto::multikey(0xe7, &decoy) 400 }, 401 { 402 "id": format!("{SQUID}#atproto"), 403 "type": "Multikey", 404 "controller": SQUID, 405 "publicKeyMultibase": knot_types::crypto::multikey(0xe7, &real) 406 } 407 ], 408 "service": [{ 409 "id": "#atproto_pds", 410 "type": "AtprotoPersonalDataServer", 411 "serviceEndpoint": "https://pds.oyster.cafe" 412 }] 413 })); 414 let identity = identity_from_document(&did(SQUID), &document).unwrap(); 415 assert_eq!(identity.signing_key.bytes.as_ref(), real.as_slice()); 416 assert_ne!(identity.signing_key.bytes.as_ref(), decoy.as_slice()); 417 } 418 419 #[test] 420 fn a_legacy_secp256k1_verification_method_resolves() { 421 let body = did_doc(DocSpec { 422 id: SQUID, 423 signing: &signer(5), 424 handle: "nel.pet", 425 pds: "https://pds.oyster.cafe", 426 method: MethodKind::LegacyK256, 427 }); 428 let identity = identity_from_document(&did(SQUID), &body).unwrap(); 429 assert_eq!( 430 identity.signing_key.bytes.as_ref(), 431 sec1(&signer(5)).as_slice() 432 ); 433 assert!(matches!(identity.signing_key.codec, KeyCodec::Secp256k1)); 434 } 435 436 #[test] 437 fn document_publishes_key_matches_only_on_codec_and_bytes() { 438 let published = did_doc(DocSpec { 439 id: SQUID, 440 signing: &signer(5), 441 handle: "nel.pet", 442 pds: "https://pds.oyster.cafe", 443 method: MethodKind::LegacyK256, 444 }); 445 document_publishes_key( 446 &RepoDid::new(SQUID).unwrap(), 447 &published, 448 &PublicKeyBytes::from_bytes(sec1(&signer(5))), 449 ) 450 .unwrap(); 451 452 let foreign = did_doc(DocSpec { 453 id: SQUID, 454 signing: &signer(5), 455 handle: "nel.pet", 456 pds: "https://pds.oyster.cafe", 457 method: MethodKind::LegacyP256, 458 }); 459 let error = document_publishes_key( 460 &RepoDid::new(SQUID).unwrap(), 461 &foreign, 462 &PublicKeyBytes::from_bytes(sec1(&signer(5))), 463 ) 464 .unwrap_err(); 465 assert!( 466 matches!(error, ResolveError::ExpectedKeyAbsent { .. }), 467 "the same bytes under a foreign codec mustn't satisfy publishes_key, got {error:?}" 468 ); 469 } 470 471 struct DocCase { 472 name: &'static str, 473 requested: &'static str, 474 body: fn() -> Bytes, 475 expect: fn(&Result<Identity, ResolveError>) -> bool, 476 } 477 478 const DOC_CASES: &[DocCase] = &[ 479 DocCase { 480 name: "document declares no atproto_pds service", 481 requested: SQUID, 482 body: || { 483 body(serde_json::json!({ 484 "id": SQUID, 485 "verificationMethod": [{ 486 "id": format!("{SQUID}#atproto"), 487 "type": "Multikey", 488 "controller": SQUID, 489 "publicKeyMultibase": sample_key() 490 }] 491 })) 492 }, 493 expect: |r| matches!(r, Err(ResolveError::MissingPds)), 494 }, 495 DocCase { 496 name: "document declares no signing key", 497 requested: SQUID, 498 body: || { 499 body(serde_json::json!({ 500 "id": SQUID, 501 "service": [{ 502 "id": "#atproto_pds", 503 "type": "AtprotoPersonalDataServer", 504 "serviceEndpoint": "https://pds.oyster.cafe" 505 }] 506 })) 507 }, 508 expect: |r| matches!(r, Err(ResolveError::MissingSigningKey)), 509 }, 510 DocCase { 511 name: "body isn't valid json", 512 requested: SQUID, 513 body: || Bytes::from_static(b"not json"), 514 expect: |r| matches!(r, Err(ResolveError::Malformed(_))), 515 }, 516 DocCase { 517 name: "legacy key without a multibase prefix", 518 requested: SQUID, 519 body: || { 520 legacy_document( 521 "EcdsaSecp256k1VerificationKey2019", 522 &bs58::encode(sec1(&signer(5))).into_string(), 523 ) 524 }, 525 expect: |r| matches!(r, Err(ResolveError::BadSigningKey(_))), 526 }, 527 DocCase { 528 name: "unsupported verification method type", 529 requested: SQUID, 530 body: || { 531 legacy_document( 532 "JsonWebKey2020", 533 &format!("z{}", bs58::encode(sec1(&signer(5))).into_string()), 534 ) 535 }, 536 expect: |r| matches!(r, Err(ResolveError::MissingSigningKey)), 537 }, 538 DocCase { 539 name: "document has only a non-atproto method", 540 requested: SQUID, 541 body: || { 542 body(serde_json::json!({ 543 "id": SQUID, 544 "verificationMethod": [{ 545 "id": format!("{SQUID}#extra"), 546 "type": "Multikey", 547 "controller": SQUID, 548 "publicKeyMultibase": knot_types::crypto::multikey(0xe7, &sec1(&signer(3))) 549 }], 550 "service": [{ 551 "id": "#atproto_pds", 552 "type": "AtprotoPersonalDataServer", 553 "serviceEndpoint": "https://pds.oyster.cafe" 554 }] 555 })) 556 }, 557 expect: |r| matches!(r, Err(ResolveError::MissingSigningKey)), 558 }, 559 DocCase { 560 name: "knot repo did:plc isn't resolvable as an account", 561 requested: "did:plc:anemone", 562 body: || { 563 did_doc(DocSpec { 564 id: "did:plc:anemone", 565 signing: &signer(5), 566 handle: "nel.pet", 567 pds: "https://knot.oyster.cafe/repo/anemone", 568 method: MethodKind::None, 569 }) 570 }, 571 expect: |r| matches!(r, Err(ResolveError::MissingSigningKey)), 572 }, 573 ]; 574 575 #[test] 576 fn identity_from_document_rejects_every_underspecified_document() { 577 DOC_CASES.iter().for_each(|case| { 578 let result = identity_from_document(&did(case.requested), &(case.body)()); 579 assert!( 580 (case.expect)(&result), 581 "case {:?} got {result:?}", 582 case.name 583 ); 584 }); 585 } 586}