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