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 / lib.rs
83 kB 2243 lines
1mod auth; 2mod identity; 3mod jwt; 4mod pointer; 5mod pubkeys; 6mod resolve; 7#[cfg(test)] 8mod test_support; 9 10pub use auth::{OauthAuthorizer, PointerAuth, PointerAuthorizer, ServiceAuth}; 11pub use identity::{ 12 IdentityError, MintNonce, PreparedRepoDid, knot_did_document, prepare_repo_did, 13}; 14pub use jwt::{JwtError, JwtNonce, ServiceJwt}; 15pub use pointer::PointerReceipt; 16pub use pubkeys::{KeyParseError, parse_authorized_key}; 17pub use resolve::{Identity, PdsEndpoint, PlcDirectory, ResolveError}; 18 19#[doc(hidden)] 20pub mod fuzz { 21 pub fn pubkey(data: &[u8]) { 22 let _ = crate::pubkeys::offered_page(data, 100); 23 let _ = crate::parse_authorized_key(&String::from_utf8_lossy(data)); 24 } 25 26 pub fn did_document(data: &[u8]) { 27 let did = knot_types::AccountDid::new("did:plc:nel").expect("constant test did is valid"); 28 let _ = crate::resolve::identity_from_document(&did, data); 29 let repo_did = knot_types::RepoDid::new("did:plc:nel").expect("constant test did is valid"); 30 let _ = crate::resolve::document_publishes_key( 31 &repo_did, 32 data, 33 &knot_runtime::PublicKeyBytes::from_bytes(data.to_vec()), 34 ); 35 } 36} 37 38#[derive(Debug, Clone, Copy, PartialEq, Eq)] 39pub enum RecordPresence { 40 Present, 41 Absent, 42} 43 44#[derive(Debug, Clone, Copy, PartialEq, Eq)] 45pub enum ReplayGuard { 46 SingleUse, 47 ReusableUntilExpiry, 48} 49 50use std::sync::Arc; 51use std::time::Duration; 52 53use futures::stream::{self, TryStreamExt}; 54use http::StatusCode; 55use knot_cache::{ 56 Admitted, AsyncCache, EntryCount, Expiring, GroupQuota, MokaFuture, Quotas, Rejected, 57 TotalQuota, Weight, 58}; 59use knot_runtime::{ 60 Clock, DnsTxtResolver, HttpRequest, HttpTransport, NetworkError, PublicKeyBytes, SystemDns, 61 UnixMicros, 62}; 63use knot_types::{ 64 AccountDid, Collection, Handle, HttpStatus, KnotId, Nsid, OfferedKey, RepoDid, RepoRkey, Rkey, 65 UnixSeconds, 66}; 67use pubkeys::Cursor; 68use serde::{Deserialize, Serialize}; 69use url::Url; 70 71const DEFAULT_TTL: Duration = Duration::from_secs(300); 72const NEGATIVE_TTL: Duration = Duration::from_secs(30); 73const STALE_TTL: Duration = Duration::from_secs(30); 74const PUBKEY_PAGE_LIMIT: u16 = 100; 75fn repo_collection() -> Nsid { 76 Nsid::new_static("sh.tangled.repo").expect("literal nsid parses") 77} 78const PUBKEY_MAX_PAGES: usize = 8; 79const PUBKEY_TTL: Duration = Duration::from_secs(30); 80const MAX_PUBKEY_CACHE_BYTES: u64 = 1 << 20; 81const PUBKEY_CACHE_ENTRY_OVERHEAD: u64 = 128; 82const PUBKEY_CACHE_KEY_OVERHEAD: u64 = 192; 83const MAX_IDENTITY_CACHE: usize = 4096; 84const MAX_SEEN_JTI: usize = 8192; 85 86const MAX_JTI_PER_ISSUER: usize = MAX_SEEN_JTI / 16; 87 88#[derive(Debug, thiserror::Error)] 89pub enum AtprotoError { 90 #[error(transparent)] 91 Resolve(#[from] ResolveError), 92 #[error(transparent)] 93 Jwt(#[from] JwtError), 94 #[error("network failure: {0}")] 95 Network(#[from] NetworkError), 96 #[error("listRecords for {did} returned HTTP {status}")] 97 ListRecords { did: AccountDid, status: HttpStatus }, 98 #[error("listRecords response wasn't valid JSON: {0}")] 99 MalformedRecords(String), 100 #[error("PDS endpoint {pds:?} isn't a usable base URL")] 101 BadPdsEndpoint { pds: String }, 102 #[error("service token {jti:?} has already been presented")] 103 Replay { jti: JwtNonce }, 104 #[error("replay-protection store is full and cannot accept another nonce")] 105 ReplayStoreSaturated, 106 #[error("issuer {issuer} has too many live replay nonces")] 107 ReplayShareExhausted { issuer: AccountDid }, 108 #[error(transparent)] 109 Identity(#[from] IdentityError), 110 #[error("plc submission for {did} returned HTTP {status}")] 111 PlcSubmit { did: RepoDid, status: HttpStatus }, 112 #[error("putRecord for {subject} returned HTTP {status}")] 113 PutRecord { 114 subject: AccountDid, 115 status: HttpStatus, 116 }, 117 #[error("getRecord for {owner} returned HTTP {status}")] 118 GetRecord { 119 owner: AccountDid, 120 status: HttpStatus, 121 }, 122 #[error("pointer record couldn't be encoded: {0}")] 123 PointerEncode(String), 124 #[error("putRecord response isn't a valid receipt: {0}")] 125 MalformedReceipt(String), 126} 127 128impl AtprotoError { 129 pub fn is_transient(&self) -> bool { 130 match self { 131 AtprotoError::Network(_) 132 | AtprotoError::ReplayStoreSaturated 133 | AtprotoError::ReplayShareExhausted { .. } => true, 134 AtprotoError::Resolve(error) => error.is_transient(), 135 AtprotoError::ListRecords { status, .. } 136 | AtprotoError::PlcSubmit { status, .. } 137 | AtprotoError::PutRecord { status, .. } 138 | AtprotoError::GetRecord { status, .. } => status.is_transient(), 139 _ => false, 140 } 141 } 142 143 pub fn is_gone(&self) -> bool { 144 match self { 145 AtprotoError::Resolve(error) => error.is_gone(), 146 AtprotoError::Jwt(_) 147 | AtprotoError::Network(_) 148 | AtprotoError::ListRecords { .. } 149 | AtprotoError::MalformedRecords(_) 150 | AtprotoError::BadPdsEndpoint { .. } 151 | AtprotoError::Replay { .. } 152 | AtprotoError::ReplayStoreSaturated 153 | AtprotoError::ReplayShareExhausted { .. } 154 | AtprotoError::Identity(_) 155 | AtprotoError::PlcSubmit { .. } 156 | AtprotoError::PutRecord { .. } 157 | AtprotoError::GetRecord { .. } 158 | AtprotoError::PointerEncode(_) 159 | AtprotoError::MalformedReceipt(_) => false, 160 } 161 } 162} 163 164#[derive(Clone)] 165enum Resolution { 166 Found(Identity), 167 Failed(ResolveError), 168 Transient(ResolveError), 169} 170 171#[derive(Clone)] 172enum HandleResolution { 173 Bound(AccountDid), 174 Unbound(ResolveError), 175 Transient(ResolveError), 176} 177 178#[derive(Clone)] 179struct Cached<R> { 180 resolution: R, 181 expires_at: UnixMicros, 182} 183 184#[derive(Clone)] 185pub enum ClaimedKeys { 186 Published(Vec<OfferedKey>), 187 Unread(Arc<AtprotoError>), 188} 189 190fn claimed_weight(cached: &Cached<ClaimedKeys>) -> Weight { 191 Weight::new(match &cached.resolution { 192 ClaimedKeys::Published(keys) => keys 193 .iter() 194 .map(|key| key.as_bytes().len() as u64 + PUBKEY_CACHE_KEY_OVERHEAD) 195 .sum::<u64>() 196 .saturating_add(PUBKEY_CACHE_ENTRY_OVERHEAD), 197 ClaimedKeys::Unread(_) => PUBKEY_CACHE_ENTRY_OVERHEAD, 198 }) 199} 200 201pub struct Atproto<H, C> { 202 http: H, 203 clock: C, 204 knot_did: KnotId, 205 plc_directory: PlcDirectory, 206 dns: Arc<dyn DnsTxtResolver>, 207 identities: MokaFuture<AccountDid, Cached<Resolution>>, 208 handles: MokaFuture<Handle, Cached<HandleResolution>>, 209 claimed: MokaFuture<AccountDid, Cached<ClaimedKeys>>, 210 seen_jti: Expiring<(AccountDid, JwtNonce), AccountDid, ()>, 211} 212 213impl<H: HttpTransport, C: Clock> Atproto<H, C> { 214 pub fn new(http: H, clock: C, knot_did: KnotId, plc_directory: PlcDirectory) -> Self { 215 Self { 216 http, 217 clock, 218 knot_did, 219 plc_directory, 220 dns: Arc::new(SystemDns::new()), 221 identities: MokaFuture::by_count(EntryCount::new(MAX_IDENTITY_CACHE as u64)), 222 handles: MokaFuture::by_count(EntryCount::new(MAX_IDENTITY_CACHE as u64)), 223 claimed: MokaFuture::by_weight(Weight::new(MAX_PUBKEY_CACHE_BYTES), claimed_weight), 224 seen_jti: Expiring::new(Quotas { 225 per_group: GroupQuota::new(MAX_JTI_PER_ISSUER), 226 total: TotalQuota::new(MAX_SEEN_JTI), 227 }), 228 } 229 } 230 231 pub fn with_dns(mut self, dns: Arc<dyn DnsTxtResolver>) -> Self { 232 self.dns = dns; 233 self 234 } 235 236 pub fn now(&self) -> UnixMicros { 237 self.clock.now_unix_micros() 238 } 239 240 pub async fn resolve_identity(&self, did: &AccountDid) -> Result<Identity, AtprotoError> { 241 self.resolve_identity_inner(did).await.map_err(Into::into) 242 } 243 244 async fn resolve_identity_inner(&self, did: &AccountDid) -> Result<Identity, ResolveError> { 245 let now = self.clock.now_unix_micros(); 246 let filled = self 247 .identities 248 .get_or_fill_if( 249 did.clone(), 250 |cached: &Cached<Resolution>| cached.expires_at <= now, 251 self.fill_identity(did, now), 252 ) 253 .await; 254 let fresh = filled.fresh; 255 match filled.value.resolution { 256 Resolution::Found(identity) => Ok(identity), 257 Resolution::Transient(error) => Err(error), 258 Resolution::Failed(error) if fresh || error.is_gone() => Err(error), 259 Resolution::Failed(_) => Err(ResolveError::RecentlyFailed { did: did.clone() }), 260 } 261 } 262 263 pub async fn resolve_handle_to_did(&self, handle: &Handle) -> Result<AccountDid, AtprotoError> { 264 let now = self.clock.now_unix_micros(); 265 let filled = self 266 .handles 267 .get_or_fill_if( 268 handle.clone(), 269 |cached: &Cached<HandleResolution>| cached.expires_at <= now, 270 self.fill_handle(handle, now), 271 ) 272 .await; 273 let fresh = filled.fresh; 274 match filled.value.resolution { 275 HandleResolution::Bound(did) => Ok(did), 276 HandleResolution::Transient(error) => Err(error.into()), 277 HandleResolution::Unbound(error) if fresh => Err(error.into()), 278 HandleResolution::Unbound(_) => Err(ResolveError::HandleRecentlyFailed { 279 handle: handle.clone(), 280 } 281 .into()), 282 } 283 } 284 285 async fn stale_handle_did(&self, handle: &Handle) -> Option<AccountDid> { 286 // `fill_handle` calls this from inside its own 287 // `or_insert_with_if` init for this key. 288 // Moka will keep the prior entry readable until init returns, 289 // so this `get` will yield the last resolved DID to re-serve, 290 // when there's a temporary outage. 291 match self.handles.get(handle).await?.resolution { 292 HandleResolution::Bound(did) => Some(did), 293 _ => None, 294 } 295 } 296 297 async fn fill_handle(&self, handle: &Handle, now: UnixMicros) -> Cached<HandleResolution> { 298 match self.verify_handle(handle).await { 299 Ok(did) => Cached { 300 resolution: HandleResolution::Bound(did), 301 expires_at: expires(now, DEFAULT_TTL), 302 }, 303 Err(error) if error.is_transient() => match self.stale_handle_did(handle).await { 304 Some(did) => Cached { 305 resolution: HandleResolution::Bound(did), 306 expires_at: expires(now, STALE_TTL), 307 }, 308 None => Cached { 309 resolution: HandleResolution::Transient(error), 310 expires_at: now, 311 }, 312 }, 313 Err(error) => Cached { 314 resolution: HandleResolution::Unbound(error), 315 expires_at: expires(now, NEGATIVE_TTL), 316 }, 317 } 318 } 319 320 async fn verify_handle(&self, handle: &Handle) -> Result<AccountDid, ResolveError> { 321 let candidate = match self.dns_txt_did(handle).await { 322 Ok(Some(did)) => did, 323 Ok(None) => self.wellknown_did(handle).await?, 324 Err(dns_error) if dns_error.is_transient() => match self.wellknown_did(handle).await { 325 Ok(did) => did, 326 Err(_) => return Err(dns_error), 327 }, 328 Err(dns_error) => return Err(dns_error), 329 }; 330 let identity = self.resolve_identity_inner(&candidate).await?; 331 if identity.claims_handle(handle) { 332 Ok(candidate) 333 } else { 334 Err(ResolveError::HandleMismatch { 335 handle: handle.clone(), 336 resolved: candidate, 337 claimed: identity.primary_handle().cloned(), 338 }) 339 } 340 } 341 342 async fn dns_txt_did(&self, handle: &Handle) -> Result<Option<AccountDid>, ResolveError> { 343 let records = self 344 .dns 345 .lookup_txt(format!("_atproto.{}", handle.as_str())) 346 .await?; 347 let dids = records 348 .iter() 349 .filter_map(|record| record.trim().strip_prefix("did=").map(str::trim)) 350 .map(|value| { 351 AccountDid::new(value).map_err(|_| ResolveError::HandleForwardMalformed { 352 handle: handle.clone(), 353 value: value.to_string(), 354 }) 355 }) 356 .collect::<Result<Vec<AccountDid>, ResolveError>>()?; 357 let distinct = dids 358 .iter() 359 .map(AccountDid::as_str) 360 .collect::<std::collections::BTreeSet<&str>>() 361 .len(); 362 match distinct { 363 0 => Ok(None), 364 1 => Ok(dids.into_iter().next()), 365 _ => Err(ResolveError::HandleAmbiguous { 366 handle: handle.clone(), 367 }), 368 } 369 } 370 371 async fn wellknown_did(&self, handle: &Handle) -> Result<AccountDid, ResolveError> { 372 let url = Url::parse(&format!( 373 "https://{}/.well-known/atproto-did", 374 handle.as_str() 375 )) 376 .map_err(|_| ResolveError::HandleUnresolvable { 377 handle: handle.clone(), 378 })?; 379 resolve::guard_fetch_url(&url)?; 380 let response = self 381 .http 382 .execute(HttpRequest::get(url)) 383 .await 384 .map_err(ResolveError::from)?; 385 if !response.status.is_success() { 386 let status = HttpStatus::from(response.status); 387 if status.is_transient() { 388 return Err(ResolveError::Status { status }); 389 } 390 return Err(ResolveError::HandleUnresolvable { 391 handle: handle.clone(), 392 }); 393 } 394 let value = std::str::from_utf8(&response.body) 395 .map_err(|_| ResolveError::HandleUnresolvable { 396 handle: handle.clone(), 397 })? 398 .trim(); 399 AccountDid::new(value).map_err(|_| ResolveError::HandleForwardMalformed { 400 handle: handle.clone(), 401 value: value.to_string(), 402 }) 403 } 404 405 async fn fill_identity(&self, did: &AccountDid, now: UnixMicros) -> Cached<Resolution> { 406 match self.fetch_identity(did).await { 407 Ok(identity) => Cached { 408 resolution: Resolution::Found(identity), 409 expires_at: expires(now, DEFAULT_TTL), 410 }, 411 Err(error) if warrants_negative_cache(&error) => Cached { 412 resolution: Resolution::Failed(error), 413 expires_at: expires(now, NEGATIVE_TTL), 414 }, 415 Err(error) => Cached { 416 resolution: Resolution::Transient(error), 417 expires_at: now, 418 }, 419 } 420 } 421 422 async fn fetch_identity(&self, did: &AccountDid) -> Result<Identity, ResolveError> { 423 let url = resolve::document_url(did, &self.plc_directory)?; 424 resolve::guard_fetch_url(&url)?; 425 let response = self 426 .http 427 .execute(HttpRequest::get(url)) 428 .await 429 .map_err(ResolveError::from)?; 430 if !response.status.is_success() { 431 let status = HttpStatus::from(response.status); 432 return match status.get() { 433 404 | 410 => Err(ResolveError::Gone { 434 did: did.clone(), 435 status, 436 }), 437 _ => Err(ResolveError::Status { status }), 438 }; 439 } 440 resolve::identity_from_document(did, &response.body) 441 } 442 443 pub fn document_url(&self, did: &AccountDid) -> Result<Url, ResolveError> { 444 resolve::document_url(did, &self.plc_directory) 445 } 446 447 pub async fn resolve_pubkeys(&self, did: &AccountDid) -> Result<Vec<OfferedKey>, AtprotoError> { 448 let identity = self.resolve_identity(did).await?; 449 self.pubkeys_at(&identity, did).await 450 } 451 452 pub async fn claimed_pubkeys(&self, did: &AccountDid) -> ClaimedKeys { 453 let now = self.clock.now_unix_micros(); 454 let filled = self 455 .claimed 456 .get_or_fill_if( 457 did.clone(), 458 |cached: &Cached<ClaimedKeys>| cached.expires_at <= now, 459 self.fill_claimed(did, now), 460 ) 461 .await; 462 filled.value.resolution 463 } 464 465 async fn fill_claimed(&self, did: &AccountDid, now: UnixMicros) -> Cached<ClaimedKeys> { 466 match self.resolve_pubkeys(did).await { 467 Ok(keys) => Cached { 468 resolution: ClaimedKeys::Published(keys), 469 expires_at: expires(now, PUBKEY_TTL), 470 }, 471 Err(error) => Cached { 472 expires_at: match error.is_transient() { 473 true => now, 474 false => expires(now, NEGATIVE_TTL), 475 }, 476 resolution: ClaimedKeys::Unread(Arc::new(error)), 477 }, 478 } 479 } 480 481 pub async fn pubkeys_at( 482 &self, 483 identity: &Identity, 484 did: &AccountDid, 485 ) -> Result<Vec<OfferedKey>, AtprotoError> { 486 let http = &self.http; 487 let pds = &identity.pds; 488 let pages = stream::try_unfold(Page::First(PUBKEY_MAX_PAGES), move |state| async move { 489 let (cursor, budget) = match state { 490 Page::Done | Page::First(0) | Page::Next(_, 0) => { 491 return Ok::<_, AtprotoError>(None); 492 } 493 Page::First(budget) => (None, budget), 494 Page::Next(cursor, budget) => (Some(cursor), budget), 495 }; 496 let url = list_records_url(pds, did, cursor.as_ref())?; 497 resolve::guard_fetch_url(&url)?; 498 let response = http.execute(HttpRequest::get(url)).await?; 499 if !response.status.is_success() { 500 return Err(AtprotoError::ListRecords { 501 did: did.clone(), 502 status: HttpStatus::from(response.status), 503 }); 504 } 505 let page = pubkeys::offered_page(&response.body, PUBKEY_PAGE_LIMIT as usize) 506 .map_err(|error| AtprotoError::MalformedRecords(error.to_string()))?; 507 let next = match page.cursor { 508 Some(cursor) => Page::Next(cursor, budget - 1), 509 None => Page::Done, 510 }; 511 Ok(Some((page.keys, next))) 512 }); 513 pages 514 .try_fold(Vec::new(), |mut acc, keys| async move { 515 acc.extend(keys); 516 Ok(acc) 517 }) 518 .await 519 } 520 521 pub async fn repo_record_present( 522 &self, 523 owner: &AccountDid, 524 rkey: &RepoRkey, 525 ) -> Result<RecordPresence, AtprotoError> { 526 let identity = self.resolve_identity(owner).await?; 527 let url = get_record_url(&identity.pds, owner, &repo_collection(), rkey)?; 528 resolve::guard_fetch_url(&url)?; 529 let response = self.http.execute(HttpRequest::get(url)).await?; 530 match response.status { 531 status if status.is_success() => Ok(RecordPresence::Present), 532 StatusCode::NOT_FOUND => Ok(RecordPresence::Absent), 533 StatusCode::BAD_REQUEST if record_not_found(response.body.as_ref()) => { 534 Ok(RecordPresence::Absent) 535 } 536 status => Err(AtprotoError::GetRecord { 537 owner: owner.clone(), 538 status: HttpStatus::from(status), 539 }), 540 } 541 } 542 543 pub async fn verify_service_jwt( 544 &self, 545 token: &ServiceJwt, 546 method: &Nsid, 547 ) -> Result<AccountDid, AtprotoError> { 548 self.verify_service_jwt_guarded(token, method, ReplayGuard::SingleUse) 549 .await 550 } 551 552 pub async fn verify_service_jwt_guarded( 553 &self, 554 token: &ServiceJwt, 555 method: &Nsid, 556 replay: ReplayGuard, 557 ) -> Result<AccountDid, AtprotoError> { 558 let parsed = jwt::parse(token)?; 559 let issuer = jwt::issuer(&parsed)?; 560 let now_micros = self.clock.now_unix_micros(); 561 let now = UnixSeconds::new((now_micros.get() / 1_000_000) as i64); 562 563 jwt::check_claims(&parsed, &self.knot_did, method, now)?; 564 let jti = jwt::nonce(&parsed)?; 565 566 let identity = self.resolve_identity(&issuer).await?; 567 jwt::verify_signature(&parsed, &identity.signing_key)?; 568 569 match replay { 570 ReplayGuard::SingleUse => { 571 let exp = UnixSeconds::new(parsed.claims().exp); 572 self.record_jti(&issuer, jti, exp, now_micros)?; 573 } 574 ReplayGuard::ReusableUntilExpiry => drop(jti), 575 } 576 Ok(issuer) 577 } 578 579 pub async fn submit_plc_operation( 580 &self, 581 prepared: &PreparedRepoDid, 582 ) -> Result<(), AtprotoError> { 583 let account = AccountDid::from(prepared.did.clone()); 584 let url = resolve::document_url(&account, &self.plc_directory)?; 585 resolve::guard_fetch_url(&url)?; 586 let request = json_post( 587 url, 588 bytes::Bytes::copy_from_slice(prepared.operation_json()), 589 ); 590 let response = self.http.execute(request).await?; 591 if response.status.is_success() { 592 Ok(()) 593 } else { 594 Err(AtprotoError::PlcSubmit { 595 did: prepared.did.clone(), 596 status: HttpStatus::from(response.status), 597 }) 598 } 599 } 600 601 pub async fn publish_pointer<R: Collection + Serialize>( 602 &self, 603 authorizer: &dyn PointerAuthorizer, 604 subject: &AccountDid, 605 rkey: &Rkey, 606 record: &R, 607 ) -> Result<PointerReceipt, AtprotoError> { 608 let identity = self.resolve_identity(subject).await?; 609 let method = pointer::put_record_method(); 610 let url = xrpc_url(&identity.pds, &method)?; 611 resolve::guard_fetch_url(&url)?; 612 let audience = pointer::pds_service_did(&identity.pds)?; 613 let now = UnixSeconds::new((self.clock.now_unix_micros().get() / 1_000_000) as i64); 614 let body = pointer::put_record_body(subject, rkey, record)?; 615 let mut request = json_post(url, bytes::Bytes::from(body)); 616 authorizer.authorize( 617 &mut request, 618 &PointerAuth { 619 issuer: &self.knot_did, 620 audience: &audience, 621 lxm: &method, 622 now_unix: now, 623 }, 624 )?; 625 let response = self.http.execute(request).await?; 626 if !response.status.is_success() { 627 return Err(AtprotoError::PutRecord { 628 subject: subject.clone(), 629 status: HttpStatus::from(response.status), 630 }); 631 } 632 pointer::receipt_from_response(&response.body) 633 } 634 635 pub async fn verify_did_web_publishes_key( 636 &self, 637 did: &RepoDid, 638 expected: &PublicKeyBytes, 639 ) -> Result<(), AtprotoError> { 640 let url = resolve::web_document_url_for(did)?; 641 resolve::guard_fetch_url(&url)?; 642 let response = self 643 .http 644 .execute(HttpRequest::get(url)) 645 .await 646 .map_err(ResolveError::from)?; 647 if !response.status.is_success() { 648 return Err(ResolveError::Status { 649 status: HttpStatus::from(response.status), 650 } 651 .into()); 652 } 653 resolve::document_publishes_key(did, &response.body, expected).map_err(Into::into) 654 } 655 656 fn record_jti( 657 &self, 658 issuer: &AccountDid, 659 jti: JwtNonce, 660 exp: UnixSeconds, 661 now: UnixMicros, 662 ) -> Result<(), AtprotoError> { 663 let horizon = exp.saturating_add_secs(jwt::CLOCK_SKEW_SECS).get().max(0) as u64; 664 let expires_at = UnixMicros::new(horizon.saturating_mul(1_000_000)); 665 match self.seen_jti.admit( 666 (issuer.clone(), jti.clone()), 667 issuer.clone(), 668 (), 669 expires_at, 670 now, 671 ) { 672 Ok(Admitted::Inserted) => Ok(()), 673 Ok(Admitted::Occupied(())) => Err(AtprotoError::Replay { jti }), 674 Err(Rejected::Total) => Err(AtprotoError::ReplayStoreSaturated), 675 Err(Rejected::Group) => Err(AtprotoError::ReplayShareExhausted { 676 issuer: issuer.clone(), 677 }), 678 } 679 } 680} 681 682fn expires(now: UnixMicros, ttl: Duration) -> UnixMicros { 683 let micros = u64::try_from(ttl.as_micros()).unwrap_or(u64::MAX); 684 UnixMicros::new(now.get().saturating_add(micros)) 685} 686 687fn warrants_negative_cache(error: &ResolveError) -> bool { 688 match error { 689 ResolveError::Status { status } => { 690 (400..500).contains(&status.get()) && status.get() != 429 691 } 692 ResolveError::Gone { .. } 693 | ResolveError::Malformed(_) 694 | ResolveError::IdMismatch { .. } 695 | ResolveError::BadSigningKey(_) 696 | ResolveError::BadPds { .. } => true, 697 _ => false, 698 } 699} 700 701enum Page { 702 First(usize), 703 Next(Cursor, usize), 704 Done, 705} 706 707fn json_post(url: Url, body: bytes::Bytes) -> HttpRequest { 708 let mut request = HttpRequest::post(url, body); 709 request.headers.insert( 710 http::header::CONTENT_TYPE, 711 http::HeaderValue::from_static("application/json"), 712 ); 713 request 714} 715 716fn xrpc_url(pds: &PdsEndpoint, method: &Nsid) -> Result<Url, AtprotoError> { 717 let mut url = pds.url().clone(); 718 url.path_segments_mut() 719 .map_err(|_| AtprotoError::BadPdsEndpoint { 720 pds: pds.url().as_str().to_string(), 721 })? 722 .pop_if_empty() 723 .extend(["xrpc", method.as_str()]); 724 Ok(url) 725} 726 727fn list_records_url( 728 pds: &PdsEndpoint, 729 did: &AccountDid, 730 cursor: Option<&Cursor>, 731) -> Result<Url, AtprotoError> { 732 let method: Nsid = 733 Nsid::new_static("com.atproto.repo.listRecords").expect("literal nsid parses"); 734 let collection: Nsid = Nsid::new_static("sh.tangled.publicKey").expect("literal nsid parses"); 735 let mut url = xrpc_url(pds, &method)?; 736 url.query_pairs_mut() 737 .append_pair("repo", did.as_str()) 738 .append_pair("collection", collection.as_str()) 739 .append_pair("limit", &PUBKEY_PAGE_LIMIT.to_string()); 740 if let Some(cursor) = cursor { 741 url.query_pairs_mut().append_pair("cursor", cursor.as_str()); 742 } 743 Ok(url) 744} 745 746#[derive(Deserialize)] 747struct XrpcErrorBody { 748 error: String, 749} 750 751fn record_not_found(body: &[u8]) -> bool { 752 serde_json::from_slice::<XrpcErrorBody>(body) 753 .is_ok_and(|parsed| parsed.error == "RecordNotFound") 754} 755 756fn get_record_url( 757 pds: &PdsEndpoint, 758 owner: &AccountDid, 759 collection: &Nsid, 760 rkey: &RepoRkey, 761) -> Result<Url, AtprotoError> { 762 let method: Nsid = Nsid::new_static("com.atproto.repo.getRecord").expect("literal nsid parses"); 763 let mut url = xrpc_url(pds, &method)?; 764 url.query_pairs_mut() 765 .append_pair("repo", owner.as_str()) 766 .append_pair("collection", collection.as_str()) 767 .append_pair("rkey", rkey.as_str()); 768 Ok(url) 769} 770 771#[cfg(test)] 772mod tests { 773 use super::*; 774 use crate::test_support::*; 775 use bytes::Bytes; 776 use futures::StreamExt; 777 use http::StatusCode; 778 use knot_runtime::{DnsTxtResolver, FakeDns, FakeHttp, ManualClock, NetworkError}; 779 use std::sync::atomic::{AtomicUsize, Ordering}; 780 use std::sync::{Arc, Mutex}; 781 782 const POINTER_RKEY: &str = "3jzfcijpj2z2a"; 783 const POINTER_CID: &str = "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"; 784 785 fn squid_doc(signing: &k256::ecdsa::SigningKey) -> Bytes { 786 did_doc(DocSpec { 787 id: SQUID, 788 signing, 789 handle: "nel.pet", 790 pds: "https://pds.oyster.cafe", 791 method: MethodKind::Multikey, 792 }) 793 } 794 795 fn resolver<T: HttpTransport>(dns: impl DnsTxtResolver, http: T) -> Atproto<T, ManualClock> { 796 Atproto::new(http, clock(), knot_did(KNOT), plc()).with_dns(Arc::new(dns)) 797 } 798 799 #[tokio::test] 800 async fn an_identity_is_resolved_from_a_did_document() { 801 let signing = signer(9); 802 let http = FakeHttp::new(move |request| { 803 assert_eq!(request.url.as_str(), "https://plc.directory/did:plc:squid"); 804 Ok(ok(squid_doc(&signing))) 805 }); 806 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 807 let identity = atproto.resolve_identity(&did(SQUID)).await.unwrap(); 808 assert_eq!(identity.pds.url().as_str(), "https://pds.oyster.cafe/"); 809 assert_eq!(identity.primary_handle().unwrap().as_str(), "nel.pet"); 810 } 811 812 #[tokio::test] 813 async fn a_second_resolution_is_served_from_cache() { 814 let signing = signer(9); 815 let hits = Arc::new(AtomicUsize::new(0)); 816 let counter = hits.clone(); 817 let http = FakeHttp::new(move |_| { 818 counter.fetch_add(1, Ordering::SeqCst); 819 Ok(ok(squid_doc(&signing))) 820 }); 821 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 822 atproto.resolve_identity(&did(SQUID)).await.unwrap(); 823 atproto.resolve_identity(&did(SQUID)).await.unwrap(); 824 assert_eq!(hits.load(Ordering::SeqCst), 1); 825 } 826 827 #[tokio::test] 828 async fn an_expired_cache_entry_is_refetched() { 829 let signing = signer(9); 830 let hits = Arc::new(AtomicUsize::new(0)); 831 let counter = hits.clone(); 832 let http = FakeHttp::new(move |_| { 833 counter.fetch_add(1, Ordering::SeqCst); 834 Ok(ok(squid_doc(&signing))) 835 }); 836 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 837 atproto.resolve_identity(&did(SQUID)).await.unwrap(); 838 atproto 839 .clock 840 .advance(DEFAULT_TTL + Duration::from_micros(1)); 841 atproto.resolve_identity(&did(SQUID)).await.unwrap(); 842 assert_eq!(hits.load(Ordering::SeqCst), 2); 843 } 844 845 #[tokio::test] 846 async fn a_handle_resolves_bidirectionally_via_dns_and_is_cached() { 847 let signing = signer(9); 848 let dns_hits = Arc::new(AtomicUsize::new(0)); 849 let counter = dns_hits.clone(); 850 let dns = FakeDns::new(move |name: &str| { 851 assert_eq!(name, "_atproto.nel.pet"); 852 counter.fetch_add(1, Ordering::SeqCst); 853 Ok(vec!["did=did:plc:squid".to_string()]) 854 }); 855 let http = FakeHttp::new(move |request| { 856 assert_eq!(request.url.as_str(), "https://plc.directory/did:plc:squid"); 857 let mut doc: serde_json::Value = serde_json::from_slice(&squid_doc(&signing)).unwrap(); 858 doc["alsoKnownAs"] = serde_json::json!(["at://olaren.dev", "at://nel.pet"]); 859 Ok(ok(Bytes::from(serde_json::to_vec(&doc).unwrap()))) 860 }); 861 let atproto = resolver(dns, http); 862 let owner = handle("nel.pet"); 863 assert_eq!( 864 atproto 865 .resolve_handle_to_did(&owner) 866 .await 867 .unwrap() 868 .as_str(), 869 "did:plc:squid", 870 "a handle listed anywhere in alsoKnownAs must resolve, even when it isn't first" 871 ); 872 assert_eq!( 873 atproto 874 .resolve_handle_to_did(&owner) 875 .await 876 .unwrap() 877 .as_str(), 878 "did:plc:squid" 879 ); 880 assert_eq!( 881 dns_hits.load(Ordering::SeqCst), 882 1, 883 "a resolved handle is served from cache" 884 ); 885 } 886 887 #[tokio::test] 888 async fn a_handle_falls_back_to_well_known_when_dns_is_empty_or_transient() { 889 let well_known = || { 890 let signing = signer(9); 891 FakeHttp::new(move |request| match request.url.as_str() { 892 "https://nel.pet/.well-known/atproto-did" => { 893 Ok(ok(Bytes::from_static(b"did:plc:squid\n"))) 894 } 895 "https://plc.directory/did:plc:squid" => Ok(ok(squid_doc(&signing))), 896 other => panic!("unexpected url {other}"), 897 }) 898 }; 899 let empty = resolver(FakeDns::new(|_| Ok(Vec::new())), well_known()); 900 let transient = resolver( 901 FakeDns::new(|_| Err(NetworkError::Request("dns unreachable".to_string()))), 902 well_known(), 903 ); 904 for atproto in [empty, transient] { 905 assert_eq!( 906 atproto 907 .resolve_handle_to_did(&handle("nel.pet")) 908 .await 909 .unwrap() 910 .as_str(), 911 "did:plc:squid" 912 ); 913 } 914 } 915 916 #[tokio::test] 917 async fn a_handle_is_rejected_when_ambiguous_or_disowned() { 918 let ambiguous = resolver( 919 FakeDns::new(|_| { 920 Ok(vec![ 921 "did=did:plc:squid".to_string(), 922 "did=did:plc:limpet".to_string(), 923 ]) 924 }), 925 FakeHttp::new(|_| panic!("resolution must stop before any fetch")), 926 ); 927 assert!(matches!( 928 ambiguous 929 .resolve_handle_to_did(&handle("nel.pet")) 930 .await 931 .unwrap_err(), 932 AtprotoError::Resolve(ResolveError::HandleAmbiguous { .. }) 933 )); 934 935 let signing = signer(9); 936 let disowned = resolver( 937 FakeDns::new(|_| Ok(vec!["did=did:plc:squid".to_string()])), 938 FakeHttp::new(move |_| Ok(ok(squid_doc(&signing)))), 939 ); 940 assert!(matches!( 941 disowned 942 .resolve_handle_to_did(&handle("olaren.dev")) 943 .await 944 .unwrap_err(), 945 AtprotoError::Resolve(ResolveError::HandleMismatch { .. }) 946 )); 947 } 948 949 #[tokio::test] 950 async fn handle_failures_cache_by_class() { 951 let owner = handle("nel.pet"); 952 953 let hits = Arc::new(AtomicUsize::new(0)); 954 let counter = hits.clone(); 955 let unresolvable = resolver( 956 FakeDns::new(move |_| { 957 counter.fetch_add(1, Ordering::SeqCst); 958 Ok(Vec::new()) 959 }), 960 FakeHttp::new(|_| Ok(status(StatusCode::NOT_FOUND, Bytes::new()))), 961 ); 962 assert!(matches!( 963 unresolvable 964 .resolve_handle_to_did(&owner) 965 .await 966 .unwrap_err(), 967 AtprotoError::Resolve(ResolveError::HandleUnresolvable { .. }) 968 )); 969 assert!(matches!( 970 unresolvable 971 .resolve_handle_to_did(&owner) 972 .await 973 .unwrap_err(), 974 AtprotoError::Resolve(ResolveError::HandleRecentlyFailed { .. }) 975 )); 976 assert_eq!( 977 hits.load(Ordering::SeqCst), 978 1, 979 "an unresolvable handle is resolved once then served from the negative cache" 980 ); 981 982 let hits = Arc::new(AtomicUsize::new(0)); 983 let counter = hits.clone(); 984 let transient = resolver( 985 FakeDns::new(move |_| { 986 counter.fetch_add(1, Ordering::SeqCst); 987 Ok(Vec::new()) 988 }), 989 FakeHttp::new(|_| Ok(status(StatusCode::SERVICE_UNAVAILABLE, Bytes::new()))), 990 ); 991 transient.resolve_handle_to_did(&owner).await.unwrap_err(); 992 transient.resolve_handle_to_did(&owner).await.unwrap_err(); 993 assert_eq!( 994 hits.load(Ordering::SeqCst), 995 2, 996 "a transient well-known status mustn't be negatively cached" 997 ); 998 } 999 1000 #[tokio::test] 1001 async fn a_transient_outage_serves_the_last_good_did() { 1002 let signing = signer(9); 1003 let outage = Arc::new(AtomicUsize::new(0)); 1004 let switch = outage.clone(); 1005 let dns = FakeDns::new(move |_| match switch.load(Ordering::SeqCst) { 1006 0 => Ok(vec!["did=did:plc:squid".to_string()]), 1007 _ => Err(NetworkError::Request("dns unreachable".to_string())), 1008 }); 1009 let atproto = resolver(dns, FakeHttp::new(move |_| Ok(ok(squid_doc(&signing))))); 1010 let owner = handle("nel.pet"); 1011 assert_eq!( 1012 atproto 1013 .resolve_handle_to_did(&owner) 1014 .await 1015 .unwrap() 1016 .as_str(), 1017 "did:plc:squid" 1018 ); 1019 1020 atproto.clock.advance(DEFAULT_TTL + Duration::from_secs(1)); 1021 outage.store(1, Ordering::SeqCst); 1022 assert_eq!( 1023 atproto 1024 .resolve_handle_to_did(&owner) 1025 .await 1026 .unwrap() 1027 .as_str(), 1028 "did:plc:squid", 1029 "a transient outage must serve the last resolved DID" 1030 ); 1031 1032 atproto.clock.advance(STALE_TTL + Duration::from_secs(1)); 1033 outage.store(0, Ordering::SeqCst); 1034 assert_eq!( 1035 atproto 1036 .resolve_handle_to_did(&owner) 1037 .await 1038 .unwrap() 1039 .as_str(), 1040 "did:plc:squid" 1041 ); 1042 } 1043 1044 #[tokio::test] 1045 async fn a_404_identity_is_negatively_cached() { 1046 let hits = Arc::new(AtomicUsize::new(0)); 1047 let counter = hits.clone(); 1048 let http = FakeHttp::new(move |_| { 1049 counter.fetch_add(1, Ordering::SeqCst); 1050 Ok(status(StatusCode::NOT_FOUND, Bytes::new())) 1051 }); 1052 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1053 let first = atproto.resolve_identity(&did(SQUID)).await.unwrap_err(); 1054 assert!( 1055 matches!(first, AtprotoError::Resolve(ResolveError::Gone { status, .. }) if status.get() == 404), 1056 "got {first:?}" 1057 ); 1058 let second = atproto.resolve_identity(&did(SQUID)).await.unwrap_err(); 1059 assert!( 1060 matches!(second, AtprotoError::Resolve(ResolveError::Gone { .. })), 1061 "a caller that acts on a missing account must see the same answer from the cache as \ 1062 from the fetch, or it acts on the first read only: got {second:?}" 1063 ); 1064 assert_eq!( 1065 hits.load(Ordering::SeqCst), 1066 1, 1067 "404 is served from the negative cache" 1068 ); 1069 } 1070 1071 #[tokio::test] 1072 async fn a_transient_5xx_identity_is_not_cached() { 1073 let hits = Arc::new(AtomicUsize::new(0)); 1074 let counter = hits.clone(); 1075 let http = FakeHttp::new(move |_| { 1076 counter.fetch_add(1, Ordering::SeqCst); 1077 Ok(status(StatusCode::SERVICE_UNAVAILABLE, Bytes::new())) 1078 }); 1079 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1080 let _ = atproto.resolve_identity(&did(SQUID)).await.unwrap_err(); 1081 let _ = atproto.resolve_identity(&did(SQUID)).await.unwrap_err(); 1082 assert_eq!( 1083 hits.load(Ordering::SeqCst), 1084 2, 1085 "a transient 503 is re-fetched instead of negatively cached" 1086 ); 1087 } 1088 1089 #[tokio::test] 1090 async fn a_document_describing_another_did_is_rejected() { 1091 let doc_key = signer(7); 1092 let http = FakeHttp::new(move |_| { 1093 Ok(ok(did_doc(DocSpec { 1094 id: LIMPET, 1095 signing: &doc_key, 1096 handle: "nel.pet", 1097 pds: "https://pds.oyster.cafe", 1098 method: MethodKind::Multikey, 1099 }))) 1100 }); 1101 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1102 let error = atproto.resolve_identity(&did(SQUID)).await.unwrap_err(); 1103 assert!( 1104 matches!( 1105 error, 1106 AtprotoError::Resolve(ResolveError::IdMismatch { .. }) 1107 ), 1108 "got {error:?}" 1109 ); 1110 } 1111 1112 #[tokio::test] 1113 async fn a_claimed_handle_is_returned_unverified() { 1114 let signing = signer(9); 1115 let http = FakeHttp::new(move |_| { 1116 Ok(ok(did_doc(DocSpec { 1117 id: SQUID, 1118 signing: &signing, 1119 handle: "olaren.dev", 1120 pds: "https://pds.oyster.cafe", 1121 method: MethodKind::Multikey, 1122 }))) 1123 }); 1124 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1125 let identity = atproto.resolve_identity(&did(SQUID)).await.unwrap(); 1126 assert_eq!( 1127 identity.primary_handle().unwrap().as_str(), 1128 "olaren.dev", 1129 "alsoKnownAs handle is taken at face value with no bidirectional verification" 1130 ); 1131 } 1132 1133 #[tokio::test] 1134 async fn an_internal_ip_with_a_port_is_refused() { 1135 let (sink, urls) = recorder(); 1136 let http = FakeHttp::new(move |request| { 1137 sink.lock().unwrap().push(request.url.clone()); 1138 Ok(ok(Bytes::new())) 1139 }); 1140 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1141 let error = atproto 1142 .resolve_identity(&did("did:web:169.254.169.254%3A6379")) 1143 .await 1144 .unwrap_err(); 1145 assert!( 1146 matches!( 1147 error, 1148 AtprotoError::Resolve(ResolveError::BlockedHost { .. }) 1149 ), 1150 "got {error:?}" 1151 ); 1152 assert!(urls.lock().unwrap().is_empty()); 1153 } 1154 1155 #[tokio::test] 1156 async fn repo_record_presence_maps_pds_status_to_a_verdict() { 1157 let signing = signer(9); 1158 let http = FakeHttp::new(move |request| { 1159 if request.url.path().ends_with("did.json") 1160 || request.url.host_str() == Some("plc.directory") 1161 { 1162 return Ok(ok(squid_doc(&signing))); 1163 } 1164 assert!(request.url.path().ends_with("com.atproto.repo.getRecord")); 1165 assert!(request.url.query().unwrap().contains("sh.tangled.repo")); 1166 let rkey = request 1167 .url 1168 .query_pairs() 1169 .find(|(key, _)| key == "rkey") 1170 .map(|(_, value)| value.into_owned()) 1171 .unwrap_or_default(); 1172 let (st, body) = match rkey.as_str() { 1173 "present" => (StatusCode::OK, Bytes::new()), 1174 "missing" => ( 1175 StatusCode::BAD_REQUEST, 1176 Bytes::from_static(b"{\"error\":\"RecordNotFound\"}"), 1177 ), 1178 _ => (StatusCode::INTERNAL_SERVER_ERROR, Bytes::new()), 1179 }; 1180 Ok(status(st, body)) 1181 }); 1182 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1183 let owner = did(SQUID); 1184 assert_eq!( 1185 atproto 1186 .repo_record_present(&owner, &RepoRkey::new("present").unwrap()) 1187 .await 1188 .unwrap(), 1189 RecordPresence::Present 1190 ); 1191 assert_eq!( 1192 atproto 1193 .repo_record_present(&owner, &RepoRkey::new("missing").unwrap()) 1194 .await 1195 .unwrap(), 1196 RecordPresence::Absent 1197 ); 1198 assert!( 1199 atproto 1200 .repo_record_present(&owner, &RepoRkey::new("boom").unwrap()) 1201 .await 1202 .is_err(), 1203 "5xx from the PDS surfaces as an error so the caller can fall back to best-effort" 1204 ); 1205 } 1206 1207 #[tokio::test] 1208 async fn pubkey_resolution_follows_the_cursor() { 1209 let doc_key = signer(9); 1210 let list_hits = Arc::new(AtomicUsize::new(0)); 1211 let counter = list_hits.clone(); 1212 let page_one = list_body( 1213 &[ssh_line("ssh-ed25519", &[1u8; 32], "one")], 1214 Some("page-2"), 1215 ); 1216 let page_two = list_body(&[ssh_line("ssh-ed25519", &[2u8; 32], "two")], None); 1217 let http = FakeHttp::new(move |request| { 1218 if request.url.host_str() == Some("plc.directory") { 1219 return Ok(ok(squid_doc(&doc_key))); 1220 } 1221 counter.fetch_add(1, Ordering::SeqCst); 1222 let on_second = request.url.query().unwrap().contains("cursor=page-2"); 1223 Ok(ok(if on_second { 1224 page_two.clone() 1225 } else { 1226 page_one.clone() 1227 })) 1228 }); 1229 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1230 let keys = atproto.resolve_pubkeys(&did(SQUID)).await.unwrap(); 1231 assert_eq!(keys.len(), 2); 1232 assert_eq!(list_hits.load(Ordering::SeqCst), 2); 1233 } 1234 1235 #[tokio::test] 1236 async fn a_repeated_claim_is_served_from_the_cache_instead_of_the_pds() { 1237 let signing = signer(9); 1238 let line = ssh_line("ssh-ed25519", &[4u8; 32], "nel@oyster.cafe"); 1239 let expected = parse_authorized_key(&line).unwrap(); 1240 let listings = Arc::new(AtomicUsize::new(0)); 1241 let counter = listings.clone(); 1242 let body = list_body(&[line], None); 1243 let http = FakeHttp::new(move |request| { 1244 if request.url.host_str() == Some("plc.directory") { 1245 return Ok(ok(squid_doc(&signing))); 1246 } 1247 assert_eq!(request.url.host_str(), Some("pds.oyster.cafe")); 1248 assert!(request.url.path().ends_with("com.atproto.repo.listRecords")); 1249 assert!( 1250 request 1251 .url 1252 .query() 1253 .unwrap() 1254 .contains("sh.tangled.publicKey") 1255 ); 1256 counter.fetch_add(1, Ordering::SeqCst); 1257 Ok(ok(body.clone())) 1258 }); 1259 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1260 1261 let first = atproto.claimed_pubkeys(&did(SQUID)).await; 1262 assert!( 1263 matches!(&first, ClaimedKeys::Published(keys) if keys == std::slice::from_ref(&expected)) 1264 ); 1265 let second = atproto.claimed_pubkeys(&did(SQUID)).await; 1266 assert!(matches!(&second, ClaimedKeys::Published(keys) if keys == &[expected])); 1267 assert_eq!( 1268 listings.load(Ordering::SeqCst), 1269 1, 1270 "an ssh handshake asserting a login name reads the claimed account's records, so a \ 1271 repeat inside the cache turn mustn't read them again, or any stranger with an ssh \ 1272 client can make the knot fetch from that account's PDS at will" 1273 ); 1274 1275 atproto.clock.advance(PUBKEY_TTL + Duration::from_micros(1)); 1276 let _ = atproto.claimed_pubkeys(&did(SQUID)).await; 1277 assert_eq!( 1278 listings.load(Ordering::SeqCst), 1279 2, 1280 "a key published after the last read will be read once the cache turn is over" 1281 ); 1282 } 1283 1284 #[tokio::test] 1285 async fn a_refused_claim_read_is_cached_and_an_outage_is_retried() { 1286 let refusal = Arc::new(Mutex::new(StatusCode::BAD_REQUEST)); 1287 let answered = Arc::clone(&refusal); 1288 let signing = signer(9); 1289 let listings = Arc::new(AtomicUsize::new(0)); 1290 let counter = listings.clone(); 1291 let http = FakeHttp::new(move |request| { 1292 if request.url.host_str() == Some("plc.directory") { 1293 return Ok(ok(squid_doc(&signing))); 1294 } 1295 counter.fetch_add(1, Ordering::SeqCst); 1296 Ok(status(*answered.lock().unwrap(), Bytes::new())) 1297 }); 1298 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1299 1300 assert!(matches!( 1301 atproto.claimed_pubkeys(&did(SQUID)).await, 1302 ClaimedKeys::Unread(_) 1303 )); 1304 let _ = atproto.claimed_pubkeys(&did(SQUID)).await; 1305 assert_eq!( 1306 listings.load(Ordering::SeqCst), 1307 1, 1308 "a listing the PDS refuses outright is served from the negative cache, so \ 1309 repeating the claim mustn't read the PDS again" 1310 ); 1311 1312 atproto 1313 .clock 1314 .advance(NEGATIVE_TTL + Duration::from_micros(1)); 1315 *refusal.lock().unwrap() = StatusCode::SERVICE_UNAVAILABLE; 1316 let _ = atproto.claimed_pubkeys(&did(SQUID)).await; 1317 let _ = atproto.claimed_pubkeys(&did(SQUID)).await; 1318 assert_eq!( 1319 listings.load(Ordering::SeqCst), 1320 3, 1321 "a transient failure is cached with no lifetime, so the account's next claim will \ 1322 read the PDS again instead of waiting out a negative turn" 1323 ); 1324 } 1325 1326 #[tokio::test] 1327 async fn a_plain_http_pds_is_refused() { 1328 let doc_key = signer(9); 1329 let (sink, urls) = recorder(); 1330 let http = FakeHttp::new(move |request| { 1331 sink.lock().unwrap().push(request.url.clone()); 1332 if request.url.host_str() == Some("plc.directory") { 1333 Ok(ok(did_doc(DocSpec { 1334 id: SQUID, 1335 signing: &doc_key, 1336 handle: "nel.pet", 1337 pds: "http://127.0.0.1:6379", 1338 method: MethodKind::Multikey, 1339 }))) 1340 } else { 1341 Ok(ok(list_body(&[], None))) 1342 } 1343 }); 1344 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1345 let error = atproto.resolve_pubkeys(&did(SQUID)).await.unwrap_err(); 1346 assert!( 1347 matches!( 1348 error, 1349 AtprotoError::Resolve(ResolveError::InsecureScheme { .. }) 1350 ), 1351 "got {error:?}" 1352 ); 1353 assert_eq!(urls.lock().unwrap().len(), 1); 1354 } 1355 1356 #[tokio::test] 1357 async fn a_single_page_is_bounded_by_the_pubkey_page_limit() { 1358 let signing = signer(9); 1359 let lines: Vec<String> = (0..3_000u32) 1360 .map(|seed| { 1361 let mut material = [0u8; 32]; 1362 material[..4].copy_from_slice(&seed.to_be_bytes()); 1363 ssh_line("ssh-ed25519", &material, "k") 1364 }) 1365 .collect(); 1366 let page = list_body(&lines, None); 1367 let http = FakeHttp::new(move |request| { 1368 if request.url.host_str() == Some("plc.directory") { 1369 Ok(ok(squid_doc(&signing))) 1370 } else { 1371 Ok(ok(page.clone())) 1372 } 1373 }); 1374 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1375 let keys = atproto.resolve_pubkeys(&did(SQUID)).await.unwrap(); 1376 assert_eq!( 1377 keys.len(), 1378 PUBKEY_PAGE_LIMIT as usize, 1379 "single page yields at most the page limit even when the PDS floods it" 1380 ); 1381 } 1382 1383 #[tokio::test] 1384 async fn a_service_jwt_authenticates_against_the_resolved_issuer_key() { 1385 let signing = signer(9); 1386 let doc = signing.clone(); 1387 let http = FakeHttp::new(move |_| Ok(ok(squid_doc(&doc)))); 1388 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1389 let method = member_method(); 1390 let claims = serde_json::json!({ 1391 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1392 "jti": "nonce-authenticates", "lxm": METHOD, 1393 }); 1394 let token = mint(&signing, &claims); 1395 let authed = atproto.verify_service_jwt(&token, &method).await.unwrap(); 1396 assert_eq!(authed, did(SQUID)); 1397 } 1398 1399 #[tokio::test] 1400 async fn a_service_jwt_signed_by_an_impostor_is_rejected() { 1401 let real = signer(9); 1402 let impostor = signer(3); 1403 let http = FakeHttp::new(move |_| Ok(ok(squid_doc(&real)))); 1404 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1405 let method = member_method(); 1406 let claims = serde_json::json!({ 1407 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1408 "jti": "nonce-impostor", "lxm": METHOD, 1409 }); 1410 let token = mint(&impostor, &claims); 1411 let error = atproto 1412 .verify_service_jwt(&token, &method) 1413 .await 1414 .unwrap_err(); 1415 assert!(matches!( 1416 error, 1417 AtprotoError::Jwt(JwtError::InvalidSignature) 1418 )); 1419 } 1420 1421 #[tokio::test] 1422 async fn a_replayed_token_is_rejected() { 1423 let key = signer(9); 1424 let http = FakeHttp::new(move |_| Ok(ok(squid_doc(&key)))); 1425 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1426 let method = member_method(); 1427 let claims = serde_json::json!({ 1428 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1429 "jti": "single-use-nonce", "lxm": METHOD, 1430 }); 1431 let token = mint(&signer(9), &claims); 1432 assert_eq!( 1433 atproto.verify_service_jwt(&token, &method).await.unwrap(), 1434 did(SQUID) 1435 ); 1436 let replay = atproto 1437 .verify_service_jwt(&token, &method) 1438 .await 1439 .unwrap_err(); 1440 assert!( 1441 matches!(replay, AtprotoError::Replay { .. }), 1442 "got {replay:?}" 1443 ); 1444 } 1445 1446 #[tokio::test] 1447 async fn two_issuers_may_share_a_nonce() { 1448 let squid_key = signer(1); 1449 let limpet_key = signer(2); 1450 let sq = squid_key.clone(); 1451 let li = limpet_key.clone(); 1452 let http = FakeHttp::new(move |request| { 1453 if request.url.as_str().ends_with(LIMPET) { 1454 Ok(ok(did_doc(DocSpec { 1455 id: LIMPET, 1456 signing: &li, 1457 handle: "nel.pet", 1458 pds: "https://pds.oyster.cafe", 1459 method: MethodKind::Multikey, 1460 }))) 1461 } else { 1462 Ok(ok(squid_doc(&sq))) 1463 } 1464 }); 1465 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1466 let method = member_method(); 1467 let squid = serde_json::json!({ 1468 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1469 "jti": "shared-nonce", "lxm": METHOD, 1470 }); 1471 let limpet = serde_json::json!({ 1472 "iss": LIMPET, "aud": KNOT, "exp": 1_001, "iat": 999, 1473 "jti": "shared-nonce", "lxm": METHOD, 1474 }); 1475 assert_eq!( 1476 atproto 1477 .verify_service_jwt(&mint(&squid_key, &squid), &method) 1478 .await 1479 .unwrap(), 1480 did(SQUID) 1481 ); 1482 assert_eq!( 1483 atproto 1484 .verify_service_jwt(&mint(&limpet_key, &limpet), &method) 1485 .await 1486 .unwrap(), 1487 did(LIMPET) 1488 ); 1489 } 1490 1491 #[test] 1492 fn knot_id_lowercases_its_host() { 1493 assert_eq!(knot_did("did:web:NEL.PET").as_str(), "did:web:nel.pet"); 1494 } 1495 1496 #[test] 1497 fn list_records_url_is_well_formed() { 1498 let url = list_records_url( 1499 &PdsEndpoint::new(Url::parse("https://pds.oyster.cafe").unwrap()).unwrap(), 1500 &did(SQUID), 1501 None, 1502 ) 1503 .unwrap(); 1504 assert_eq!(url.host_str(), Some("pds.oyster.cafe")); 1505 assert_eq!(url.path(), "/xrpc/com.atproto.repo.listRecords"); 1506 let query = url.query().unwrap(); 1507 assert!(query.contains("repo=did%3Aplc%3Asquid")); 1508 assert!(query.contains("collection=sh.tangled.publicKey")); 1509 } 1510 1511 #[test] 1512 fn list_records_url_preserves_a_pds_base_path() { 1513 let url = list_records_url( 1514 &PdsEndpoint::new(Url::parse("https://shared.host/account-pds").unwrap()).unwrap(), 1515 &did(SQUID), 1516 Some(&Cursor::new("page2")), 1517 ) 1518 .unwrap(); 1519 assert_eq!(url.path(), "/account-pds/xrpc/com.atproto.repo.listRecords"); 1520 assert!(url.query().unwrap().contains("cursor=page2")); 1521 } 1522 1523 struct JwtCase { 1524 name: &'static str, 1525 header: &'static [u8], 1526 mutate: fn(&mut serde_json::Value), 1527 expect: fn(&Result<AccountDid, AtprotoError>) -> bool, 1528 zero_network: bool, 1529 } 1530 1531 const JWT_HEADER: &[u8] = br#"{"alg":"ES256K","typ":"JWT"}"#; 1532 1533 const JWT_CASES: &[JwtCase] = &[ 1534 JwtCase { 1535 name: "internal-ip issuer is blocked before any fetch", 1536 header: JWT_HEADER, 1537 mutate: |c| c["iss"] = serde_json::json!("did:web:169.254.169.254"), 1538 expect: |r| { 1539 matches!( 1540 r, 1541 Err(AtprotoError::Resolve(ResolveError::BlockedHost { .. })) 1542 ) 1543 }, 1544 zero_network: true, 1545 }, 1546 JwtCase { 1547 name: "decade-long token exceeds the lifetime limit", 1548 header: JWT_HEADER, 1549 mutate: |c| { 1550 c["exp"] = serde_json::json!(1000 + 315_360_000i64); 1551 c["iat"] = serde_json::json!(1000); 1552 }, 1553 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::LifetimeTooLong { .. }))), 1554 zero_network: true, 1555 }, 1556 JwtCase { 1557 name: "audience in a different case is accepted", 1558 header: JWT_HEADER, 1559 mutate: |c| c["aud"] = serde_json::json!("did:web:NEL.PET"), 1560 expect: |r| r.is_ok(), 1561 zero_network: false, 1562 }, 1563 JwtCase { 1564 name: "legacy-typed issuer key verifies", 1565 header: JWT_HEADER, 1566 mutate: |c| c["iss"] = serde_json::json!(LIMPET), 1567 expect: |r| matches!(r, Ok(did) if did.as_str() == LIMPET), 1568 zero_network: false, 1569 }, 1570 JwtCase { 1571 name: "token addressed to another knot is refused", 1572 header: JWT_HEADER, 1573 mutate: |c| c["aud"] = serde_json::json!("did:web:somewhere.else"), 1574 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::AudienceMismatch { .. }))), 1575 zero_network: true, 1576 }, 1577 JwtCase { 1578 name: "stale token is expired before resolution", 1579 header: JWT_HEADER, 1580 mutate: |c| { 1581 c["exp"] = serde_json::json!(1); 1582 c["iat"] = serde_json::json!(0); 1583 }, 1584 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::Expired { .. }))), 1585 zero_network: true, 1586 }, 1587 JwtCase { 1588 name: "nonceless token is refused before resolution", 1589 header: JWT_HEADER, 1590 mutate: |c| { 1591 c.as_object_mut().unwrap().remove("jti"); 1592 }, 1593 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::MissingNonce))), 1594 zero_network: true, 1595 }, 1596 JwtCase { 1597 name: "alg none is refused", 1598 header: br#"{"alg":"none","typ":"JWT"}"#, 1599 mutate: |_| {}, 1600 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::Parse(_)))), 1601 zero_network: false, 1602 }, 1603 JwtCase { 1604 name: "es256 header against a k256 doc key is refused", 1605 header: br#"{"alg":"ES256","typ":"JWT"}"#, 1606 mutate: |_| {}, 1607 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::Parse(_)))), 1608 zero_network: false, 1609 }, 1610 JwtCase { 1611 name: "token type that isn't JWT is refused", 1612 header: br#"{"alg":"ES256K","typ":"secevent+jwt"}"#, 1613 mutate: |_| {}, 1614 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::UnexpectedType { .. }))), 1615 zero_network: true, 1616 }, 1617 JwtCase { 1618 name: "method mismatch is refused before resolution", 1619 header: JWT_HEADER, 1620 mutate: |c| c["lxm"] = serde_json::json!("sh.tangled.repo.delete"), 1621 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::MethodMismatch { .. }))), 1622 zero_network: true, 1623 }, 1624 JwtCase { 1625 name: "oversized nonce is refused before resolution", 1626 header: JWT_HEADER, 1627 mutate: |c| c["jti"] = serde_json::json!("n".repeat(100_000)), 1628 expect: |r| matches!(r, Err(AtprotoError::Jwt(JwtError::OversizedNonce { .. }))), 1629 zero_network: true, 1630 }, 1631 ]; 1632 1633 #[tokio::test] 1634 async fn verify_service_jwt_rejects_every_malformed_or_adversarial_token() { 1635 let method = member_method(); 1636 stream::iter(JWT_CASES) 1637 .for_each(|case| { 1638 let method = &method; 1639 async move { 1640 let doc_key = signer(1); 1641 let calls = Arc::new(AtomicUsize::new(0)); 1642 let counter = calls.clone(); 1643 let http = FakeHttp::new(move |request| { 1644 counter.fetch_add(1, Ordering::SeqCst); 1645 if request.url.as_str().ends_with(LIMPET) { 1646 Ok(ok(did_doc(DocSpec { 1647 id: LIMPET, 1648 signing: &doc_key, 1649 handle: "nel.pet", 1650 pds: "https://pds.oyster.cafe", 1651 method: MethodKind::LegacyK256, 1652 }))) 1653 } else { 1654 Ok(ok(squid_doc(&doc_key))) 1655 } 1656 }); 1657 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1658 let mut claims = serde_json::json!({ 1659 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1660 "jti": "nonce-3a", "lxm": METHOD, 1661 }); 1662 (case.mutate)(&mut claims); 1663 let token = mint_with_header(&signer(1), case.header, &claims); 1664 let result = atproto.verify_service_jwt(&token, method).await; 1665 assert!( 1666 (case.expect)(&result), 1667 "case {:?} got {result:?}", 1668 case.name 1669 ); 1670 if case.zero_network { 1671 assert_eq!( 1672 calls.load(Ordering::SeqCst), 1673 0, 1674 "case {:?} must be refused before any network resolution", 1675 case.name 1676 ); 1677 } 1678 } 1679 }) 1680 .await; 1681 } 1682 1683 #[tokio::test] 1684 async fn the_jti_replay_store_is_bounded_and_fails_closed_when_saturated() { 1685 let signing = signer(9); 1686 let doc = signing.clone(); 1687 let http = FakeHttp::new(move |request| { 1688 let host = request.url.host_str().unwrap().to_string(); 1689 let id = format!("did:web:{host}"); 1690 Ok(ok(did_doc(DocSpec { 1691 id: &id, 1692 signing: &doc, 1693 handle: "nel.pet", 1694 pds: "https://pds.oyster.cafe", 1695 method: MethodKind::Multikey, 1696 }))) 1697 }); 1698 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1699 let method = member_method(); 1700 let issuers = MAX_SEEN_JTI / MAX_JTI_PER_ISSUER; 1701 stream::iter( 1702 (0..issuers).flat_map(|issuer| (0..MAX_JTI_PER_ISSUER).map(move |i| (issuer, i))), 1703 ) 1704 .for_each(|(issuer, i)| { 1705 let atproto = &atproto; 1706 let method = &method; 1707 let signing = &signing; 1708 async move { 1709 let claims = serde_json::json!({ 1710 "iss": format!("did:web:i{issuer}.oyster.cafe"), "aud": KNOT, 1711 "exp": 1_001, "iat": 999, 1712 "jti": format!("nonce-{issuer}-{i}"), "lxm": METHOD, 1713 }); 1714 atproto 1715 .verify_service_jwt(&mint(signing, &claims), method) 1716 .await 1717 .unwrap(); 1718 } 1719 }) 1720 .await; 1721 assert_eq!(atproto.seen_jti.len(), MAX_SEEN_JTI); 1722 let overflow = serde_json::json!({ 1723 "iss": "did:web:fresh.oyster.cafe", "aud": KNOT, "exp": 1_001, "iat": 999, 1724 "jti": "nonce-overflow", "lxm": METHOD, 1725 }); 1726 let error = atproto 1727 .verify_service_jwt(&mint(&signing, &overflow), &method) 1728 .await 1729 .unwrap_err(); 1730 assert!( 1731 matches!(error, AtprotoError::ReplayStoreSaturated), 1732 "every verify past the global limit fails closed, got {error:?}" 1733 ); 1734 assert!( 1735 atproto.seen_jti.len() <= MAX_SEEN_JTI, 1736 "replay store must stay bounded, held {}", 1737 atproto.seen_jti.len() 1738 ); 1739 } 1740 1741 #[tokio::test] 1742 async fn one_issuer_cannot_hog_the_replay_store() { 1743 let signing = signer(9); 1744 let doc = signing.clone(); 1745 let http = FakeHttp::new(move |request| { 1746 let host = request.url.host_str().unwrap().to_string(); 1747 if host == "plc.directory" { 1748 Ok(ok(squid_doc(&doc))) 1749 } else { 1750 let id = format!("did:web:{host}"); 1751 Ok(ok(did_doc(DocSpec { 1752 id: &id, 1753 signing: &doc, 1754 handle: "nel.pet", 1755 pds: "https://pds.oyster.cafe", 1756 method: MethodKind::Multikey, 1757 }))) 1758 } 1759 }); 1760 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1761 let method = member_method(); 1762 stream::iter(0..MAX_JTI_PER_ISSUER) 1763 .for_each(|i| { 1764 let atproto = &atproto; 1765 let method = &method; 1766 let signing = &signing; 1767 async move { 1768 let claims = serde_json::json!({ 1769 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1770 "jti": format!("nonce-{i}"), "lxm": METHOD, 1771 }); 1772 atproto 1773 .verify_service_jwt(&mint(signing, &claims), method) 1774 .await 1775 .unwrap(); 1776 } 1777 }) 1778 .await; 1779 1780 let hogged = serde_json::json!({ 1781 "iss": SQUID, "aud": KNOT, "exp": 1_001, "iat": 999, 1782 "jti": "nonce-over-budget", "lxm": METHOD, 1783 }); 1784 let error = atproto 1785 .verify_service_jwt(&mint(&signing, &hogged), &method) 1786 .await 1787 .unwrap_err(); 1788 assert!( 1789 matches!(error, AtprotoError::ReplayShareExhausted { .. }), 1790 "issuer past its share fails closed, got {error:?}" 1791 ); 1792 1793 let bystander = serde_json::json!({ 1794 "iss": "did:web:bystander.oyster.cafe", "aud": KNOT, "exp": 1_001, "iat": 999, 1795 "jti": "nonce-bystander", "lxm": METHOD, 1796 }); 1797 atproto 1798 .verify_service_jwt(&mint(&signing, &bystander), &method) 1799 .await 1800 .expect("unrelated issuer is unaffected by the hog"); 1801 1802 atproto.clock.advance(Duration::from_secs(62)); 1803 let after_expiry = serde_json::json!({ 1804 "iss": SQUID, "aud": KNOT, "exp": 1_100, "iat": 1_050, 1805 "jti": "nonce-after-expiry", "lxm": METHOD, 1806 }); 1807 atproto 1808 .verify_service_jwt(&mint(&signing, &after_expiry), &method) 1809 .await 1810 .expect("hog recovers once its nonces expire from the store"); 1811 } 1812 1813 #[tokio::test] 1814 async fn the_identity_cache_stays_bounded_and_retains_hot_entries() { 1815 let signing = signer(9); 1816 let hot_hits = Arc::new(AtomicUsize::new(0)); 1817 let sentinel_hits = Arc::new(AtomicUsize::new(0)); 1818 let hot = hot_hits.clone(); 1819 let sentinel = sentinel_hits.clone(); 1820 let http = FakeHttp::new(move |request| { 1821 let host = request.url.host_str().unwrap().to_string(); 1822 if host == "hot.oyster.cafe" { 1823 hot.fetch_add(1, Ordering::SeqCst); 1824 } 1825 // yeah so what 1826 if host == "sentinel.oyster.cafe" { 1827 sentinel.fetch_add(1, Ordering::SeqCst); 1828 } 1829 let id = format!("did:web:{host}"); 1830 Ok(ok(did_doc(DocSpec { 1831 id: &id, 1832 signing: &signing, 1833 handle: "nel.pet", 1834 pds: "https://pds.oyster.cafe", 1835 method: MethodKind::Multikey, 1836 }))) 1837 }); 1838 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1839 let hot_did = did("did:web:hot.oyster.cafe"); 1840 stream::iter(0..64) 1841 .for_each(|_| { 1842 let atproto = &atproto; 1843 let hot_did = hot_did.clone(); 1844 async move { 1845 atproto.resolve_identity(&hot_did).await.unwrap(); 1846 } 1847 }) 1848 .await; 1849 stream::iter(0..MAX_IDENTITY_CACHE * 2) 1850 .for_each(|i| { 1851 let atproto = &atproto; 1852 let hot_did = hot_did.clone(); 1853 async move { 1854 let filler = AccountDid::new(format!("did:web:c{i}.oyster.cafe")).unwrap(); 1855 atproto.resolve_identity(&filler).await.unwrap(); 1856 if i.is_multiple_of(8) { 1857 atproto.resolve_identity(&hot_did).await.unwrap(); 1858 } 1859 } 1860 }) 1861 .await; 1862 atproto.identities.run_pending_tasks().await; 1863 assert!( 1864 atproto.identities.entry_count().get() <= MAX_IDENTITY_CACHE as u64, 1865 "cache stays bounded under a cold-DID flood" 1866 ); 1867 let before = hot_hits.load(Ordering::SeqCst); 1868 atproto.resolve_identity(&hot_did).await.unwrap(); 1869 assert_eq!( 1870 hot_hits.load(Ordering::SeqCst), 1871 before, 1872 "frequently resolved identity is retained through the flood" 1873 ); 1874 let novel = did("did:web:sentinel.oyster.cafe"); 1875 atproto.resolve_identity(&novel).await.unwrap(); 1876 atproto.resolve_identity(&novel).await.unwrap(); 1877 assert_eq!( 1878 sentinel_hits.load(Ordering::SeqCst), 1879 1, 1880 "saturated cache still admits a new entry and reuses it without a re-fetch" 1881 ); 1882 atproto.identities.run_pending_tasks().await; 1883 assert!( 1884 atproto.identities.entry_count().get() <= MAX_IDENTITY_CACHE as u64, 1885 "cache stays bounded after eviction" 1886 ); 1887 } 1888 1889 struct GatedHttp { 1890 status: StatusCode, 1891 body: Bytes, 1892 calls: Arc<AtomicUsize>, 1893 gate: Arc<tokio::sync::Notify>, 1894 } 1895 1896 impl HttpTransport for GatedHttp { 1897 fn execute(&self, _request: HttpRequest) -> knot_runtime::HttpFuture { 1898 self.calls.fetch_add(1, Ordering::SeqCst); 1899 let gate = self.gate.clone(); 1900 let response = status(self.status, self.body.clone()); 1901 Box::pin(async move { 1902 gate.notified().await; 1903 Ok(response) 1904 }) 1905 } 1906 } 1907 1908 async fn gated_wave( 1909 st: StatusCode, 1910 body: Bytes, 1911 ) -> (Vec<Result<Identity, AtprotoError>>, usize) { 1912 let calls = Arc::new(AtomicUsize::new(0)); 1913 let gate = Arc::new(tokio::sync::Notify::new()); 1914 let http = GatedHttp { 1915 status: st, 1916 body, 1917 calls: calls.clone(), 1918 gate: gate.clone(), 1919 }; 1920 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 1921 let subject = did(SQUID); 1922 let resolves = 1923 futures::future::join_all((0..2000).map(|_| atproto.resolve_identity(&subject))); 1924 let releaser = async { 1925 stream::iter(0..16) 1926 .for_each(|_| async { 1927 tokio::task::yield_now().await; 1928 }) 1929 .await; 1930 gate.notify_one(); 1931 }; 1932 let (results, ()) = futures::join!(resolves, releaser); 1933 (results, calls.load(Ordering::SeqCst)) 1934 } 1935 1936 #[tokio::test] 1937 async fn concurrent_cold_resolves_coalesce_into_one_fetch() { 1938 let (results, calls) = gated_wave(StatusCode::OK, squid_doc(&signer(9))).await; 1939 assert_eq!(results.len(), 2000); 1940 assert!(results.iter().all(|outcome| outcome.is_ok())); 1941 assert_eq!( 1942 calls, 1, 1943 "2000 concurrent resolves of one cold DID issue exactly one outbound fetch" 1944 ); 1945 } 1946 1947 #[tokio::test] 1948 async fn a_concurrent_429_wave_gets_the_real_error_and_never_poisons_the_cache() { 1949 let (results, calls) = gated_wave(StatusCode::TOO_MANY_REQUESTS, Bytes::new()).await; 1950 assert_eq!(calls, 1, "failing wave coalesces into one outbound fetch"); 1951 assert!( 1952 results.iter().all(|outcome| matches!( 1953 outcome, 1954 Err(AtprotoError::Resolve(ResolveError::Status { status })) if status.get() == 429 1955 )), 1956 "every caller in the wave receives a real 429, never a poisoned RecentlyFailed" 1957 ); 1958 } 1959 1960 #[tokio::test] 1961 async fn every_caller_in_a_coalesced_404_wave_learns_the_account_is_missing() { 1962 let (results, calls) = gated_wave(StatusCode::NOT_FOUND, Bytes::new()).await; 1963 assert_eq!(calls, 1, "404 wave coalesces into one outbound fetch"); 1964 let gone = results 1965 .iter() 1966 .filter(|outcome| { 1967 matches!( 1968 outcome, 1969 Err(AtprotoError::Resolve(ResolveError::Gone { status, .. })) if status.get() == 404 1970 ) 1971 }) 1972 .count(); 1973 assert_eq!( 1974 gone, 2000, 1975 "coalescing mustn't decide which callers learn the account is missing, since the \ 1976 callers served from the cache act on the answer the same way" 1977 ); 1978 } 1979 1980 #[tokio::test] 1981 async fn a_prepared_plc_operation_is_posted_and_a_rejection_is_typed() { 1982 let prepared = prepare_repo_did( 1983 &runtime_signer(11), 1984 &knot_types::KnotServiceUrl::new("https://knot.nel.pet").unwrap(), 1985 &repo_nonce(111), 1986 ) 1987 .unwrap(); 1988 let expected_path = format!("/{}", prepared.did.as_str()); 1989 let http = FakeHttp::new(move |request| { 1990 assert_eq!(request.method, http::Method::POST); 1991 assert_eq!(request.url.path(), expected_path); 1992 assert!(request.body.as_ref().is_some_and(|body| !body.is_empty())); 1993 Ok(ok(Bytes::new())) 1994 }); 1995 Atproto::new(http, clock(), knot_did(KNOT), plc()) 1996 .submit_plc_operation(&prepared) 1997 .await 1998 .unwrap(); 1999 2000 let rejected = prepare_repo_did( 2001 &runtime_signer(12), 2002 &knot_types::KnotServiceUrl::new("https://knot.nel.pet").unwrap(), 2003 &repo_nonce(112), 2004 ) 2005 .unwrap(); 2006 let http = FakeHttp::new(move |_| Ok(status(StatusCode::BAD_REQUEST, Bytes::new()))); 2007 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2008 assert!(matches!( 2009 atproto.submit_plc_operation(&rejected).await, 2010 Err(AtprotoError::PlcSubmit { status, .. }) if status.get() == 400 2011 )); 2012 } 2013 2014 #[tokio::test] 2015 async fn a_did_web_document_verification_covers_present_absent_and_missing() { 2016 let signing = signer(9); 2017 let doc = signing.clone(); 2018 let http = FakeHttp::new(move |request| { 2019 assert_eq!( 2020 request.url.as_str(), 2021 "https://limpet.olaren.dev/.well-known/did.json" 2022 ); 2023 Ok(ok(did_doc(DocSpec { 2024 id: "did:web:limpet.olaren.dev", 2025 signing: &doc, 2026 handle: "nel.pet", 2027 pds: "https://pds.oyster.cafe", 2028 method: MethodKind::Multikey, 2029 }))) 2030 }); 2031 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2032 atproto 2033 .verify_did_web_publishes_key( 2034 &repo_did("did:web:limpet.olaren.dev"), 2035 &PublicKeyBytes::from_bytes(sec1(&signing)), 2036 ) 2037 .await 2038 .unwrap(); 2039 2040 let published = signer(9); 2041 let http = FakeHttp::new(move |_| { 2042 Ok(ok(did_doc(DocSpec { 2043 id: "did:web:limpet.olaren.dev", 2044 signing: &published, 2045 handle: "nel.pet", 2046 pds: "https://pds.oyster.cafe", 2047 method: MethodKind::Multikey, 2048 }))) 2049 }); 2050 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2051 let error = atproto 2052 .verify_did_web_publishes_key( 2053 &repo_did("did:web:limpet.olaren.dev"), 2054 &PublicKeyBytes::from_bytes(sec1(&signer(3))), 2055 ) 2056 .await 2057 .unwrap_err(); 2058 assert!(matches!( 2059 error, 2060 AtprotoError::Resolve(ResolveError::ExpectedKeyAbsent { .. }) 2061 )); 2062 2063 let http = FakeHttp::new(|_| Ok(status(StatusCode::NOT_FOUND, Bytes::new()))); 2064 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2065 let error = atproto 2066 .verify_did_web_publishes_key( 2067 &repo_did("did:web:limpet.olaren.dev"), 2068 &PublicKeyBytes::from_bytes(vec![1, 2, 3]), 2069 ) 2070 .await 2071 .unwrap_err(); 2072 assert!(matches!( 2073 error, 2074 AtprotoError::Resolve(ResolveError::Status { status }) if status.get() == 404 2075 )); 2076 } 2077 2078 #[tokio::test] 2079 async fn a_pointer_record_is_published_to_the_subjects_pds_over_service_auth() { 2080 let doc_signer = signer(9); 2081 let knot_key = runtime_signer(21); 2082 let knot_public = knot_runtime::Signer::public_key(&knot_key); 2083 let http = FakeHttp::new(move |request| { 2084 if request.url.host_str() == Some("plc.directory") { 2085 return Ok(ok(squid_doc(&doc_signer))); 2086 } 2087 assert_eq!(request.method, http::Method::POST); 2088 assert_eq!( 2089 request.url.as_str(), 2090 "https://pds.oyster.cafe/xrpc/com.atproto.repo.putRecord" 2091 ); 2092 let bearer = request 2093 .headers 2094 .get(http::header::AUTHORIZATION) 2095 .and_then(|value| value.to_str().ok()) 2096 .and_then(|value| value.strip_prefix("Bearer ")) 2097 .expect("request includes a bearer service token"); 2098 let parsed = knot_types::service_auth::parse_jwt(bearer).unwrap(); 2099 assert_eq!(parsed.claims().iss.as_str(), KNOT); 2100 assert_eq!(parsed.claims().aud.as_str(), "did:web:pds.oyster.cafe"); 2101 assert_eq!( 2102 parsed.claims().lxm.as_ref().unwrap().as_str(), 2103 "com.atproto.repo.putRecord" 2104 ); 2105 assert!(parsed.claims().jti.is_some()); 2106 let key = knot_types::service_auth::PublicKey::from_k256_bytes(knot_public.as_bytes()) 2107 .unwrap(); 2108 knot_types::service_auth::verify_signature(&parsed, &key) 2109 .expect("token is signed by the knot key"); 2110 let body: serde_json::Value = 2111 serde_json::from_slice(request.body.as_ref().unwrap()).unwrap(); 2112 assert_eq!(body["repo"], SQUID); 2113 assert_eq!(body["collection"], "sh.tangled.knot.member"); 2114 assert_eq!(body["rkey"], POINTER_RKEY); 2115 assert_eq!(body["record"]["$type"], "sh.tangled.knot.member"); 2116 assert_eq!(body["record"]["subject"], "did:plc:lyna"); 2117 assert_eq!(body["record"]["domain"], "knot.nel.pet"); 2118 let receipt = serde_json::json!({ 2119 "uri": format!("at://{SQUID}/sh.tangled.knot.member/{POINTER_RKEY}"), 2120 "cid": POINTER_CID, 2121 }); 2122 Ok(ok(Bytes::from(serde_json::to_vec(&receipt).unwrap()))) 2123 }); 2124 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2125 let rkey = Rkey::new_owned(POINTER_RKEY).unwrap(); 2126 let receipt = atproto 2127 .publish_pointer( 2128 &ServiceAuth::new(&knot_key, &entropy(31)), 2129 &did(SQUID), 2130 &rkey, 2131 &member_pointer(), 2132 ) 2133 .await 2134 .unwrap(); 2135 assert_eq!( 2136 receipt.uri.as_str(), 2137 format!("at://{SQUID}/sh.tangled.knot.member/{POINTER_RKEY}") 2138 ); 2139 assert_eq!(receipt.cid.as_str(), POINTER_CID); 2140 } 2141 2142 async fn rejected_put_record(st: StatusCode) -> AtprotoError { 2143 let doc_signer = signer(9); 2144 let http = FakeHttp::new(move |request| { 2145 if request.url.host_str() == Some("plc.directory") { 2146 return Ok(ok(squid_doc(&doc_signer))); 2147 } 2148 Ok(status(st, Bytes::new())) 2149 }); 2150 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2151 let rkey = Rkey::new_owned(POINTER_RKEY).unwrap(); 2152 atproto 2153 .publish_pointer( 2154 &ServiceAuth::new(&runtime_signer(22), &entropy(31)), 2155 &did(SQUID), 2156 &rkey, 2157 &member_pointer(), 2158 ) 2159 .await 2160 .unwrap_err() 2161 } 2162 2163 #[tokio::test] 2164 async fn a_rejected_put_record_is_a_typed_error_and_transient_only_on_server_failure() { 2165 let server_failure = rejected_put_record(StatusCode::BAD_GATEWAY).await; 2166 assert!(matches!( 2167 server_failure, 2168 AtprotoError::PutRecord { status, .. } if status.get() == 502 2169 )); 2170 assert!(server_failure.is_transient()); 2171 2172 let rate_limited = rejected_put_record(StatusCode::TOO_MANY_REQUESTS).await; 2173 assert!(matches!( 2174 rate_limited, 2175 AtprotoError::PutRecord { status, .. } if status.get() == 429 2176 )); 2177 assert!(rate_limited.is_transient()); 2178 2179 let client_failure = rejected_put_record(StatusCode::BAD_REQUEST).await; 2180 assert!(matches!( 2181 client_failure, 2182 AtprotoError::PutRecord { status, .. } if status.get() == 400 2183 )); 2184 assert!(!client_failure.is_transient()); 2185 } 2186 2187 #[tokio::test] 2188 async fn a_malformed_put_record_receipt_is_a_typed_error() { 2189 let doc_signer = signer(9); 2190 let http = FakeHttp::new(move |request| { 2191 if request.url.host_str() == Some("plc.directory") { 2192 return Ok(ok(squid_doc(&doc_signer))); 2193 } 2194 Ok(ok(Bytes::from_static(b"not json"))) 2195 }); 2196 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2197 let rkey = Rkey::new_owned(POINTER_RKEY).unwrap(); 2198 let error = atproto 2199 .publish_pointer( 2200 &ServiceAuth::new(&runtime_signer(23), &entropy(31)), 2201 &did(SQUID), 2202 &rkey, 2203 &member_pointer(), 2204 ) 2205 .await 2206 .unwrap_err(); 2207 assert!(matches!(error, AtprotoError::MalformedReceipt(_))); 2208 } 2209 2210 #[tokio::test] 2211 async fn a_pointer_to_an_insecure_pds_endpoint_is_refused() { 2212 let doc_signer = signer(9); 2213 let http = FakeHttp::new(move |request| { 2214 assert_eq!( 2215 request.url.host_str(), 2216 Some("plc.directory"), 2217 "no request may reach the insecure PDS" 2218 ); 2219 Ok(ok(did_doc(DocSpec { 2220 id: SQUID, 2221 signing: &doc_signer, 2222 handle: "nel.pet", 2223 pds: "http://pds.oyster.cafe", 2224 method: MethodKind::Multikey, 2225 }))) 2226 }); 2227 let atproto = Atproto::new(http, clock(), knot_did(KNOT), plc()); 2228 let rkey = Rkey::new_owned(POINTER_RKEY).unwrap(); 2229 let error = atproto 2230 .publish_pointer( 2231 &ServiceAuth::new(&runtime_signer(24), &entropy(31)), 2232 &did(SQUID), 2233 &rkey, 2234 &member_pointer(), 2235 ) 2236 .await 2237 .unwrap_err(); 2238 assert!(matches!( 2239 error, 2240 AtprotoError::Resolve(ResolveError::InsecureScheme { .. }) 2241 )); 2242 } 2243}