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