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