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