Alternative ATProto PDS implementation
1//! DID utilities.
2
3use anyhow::{Context as _, Result, bail};
4use atrium_api::types::string::Did;
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::serve::Client;
9
10/// URL whitelist for DID document resolution.
11const ALLOWED_URLS: &[&str] = &["bsky.app", "bsky.chat"];
12
13#[expect(
14 clippy::arbitrary_source_item_ordering,
15 reason = "serialized data might be structured"
16)]
17#[derive(Clone, Debug, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19/// DID verification method.
20pub(crate) struct DidVerificationMethod {
21 pub id: String,
22 #[serde(rename = "type")]
23 pub ty: String,
24 pub controller: String,
25 pub public_key_multibase: String,
26}
27
28#[expect(
29 clippy::arbitrary_source_item_ordering,
30 reason = "serialized data might be structured"
31)]
32#[derive(Clone, Debug, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub(crate) struct DidService {
35 pub id: String,
36 #[serde(rename = "type")]
37 pub ty: String,
38 pub service_endpoint: Url,
39}
40
41#[expect(
42 clippy::arbitrary_source_item_ordering,
43 reason = "serialized data might be structured"
44)]
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47/// DID document.
48pub(crate) struct DidDocument {
49 #[serde(rename = "@context", skip_serializing_if = "Vec::is_empty")]
50 pub context: Vec<Url>,
51 pub id: Did,
52 #[serde(skip_serializing_if = "Vec::is_empty", default)]
53 pub verification_method: Vec<DidVerificationMethod>,
54 pub service: Vec<DidService>,
55}
56
57/// Resolve a DID document using the specified reqwest client.
58pub(crate) async fn resolve(client: &Client, did: Did) -> Result<DidDocument> {
59 let url = match did.method() {
60 "did:web" => {
61 // N.B: This is a potentially hostile operation, so we are only going to allow
62 // certain URLs for now.
63 let host = did
64 .as_str()
65 .strip_prefix("did:web:")
66 .context("invalid DID format")?;
67
68 if !ALLOWED_URLS.iter().any(|u| host.ends_with(u)) {
69 bail!("forbidden URL {host}");
70 }
71
72 format!("https://{host}/.well-known/did.json")
73 }
74 "did:plc" => {
75 format!("https://plc.directory/{}", did.as_str())
76 }
77 m => bail!("unknown did method {m}"),
78 }
79 .parse::<Url>()
80 .context("failed to resolve DID URL")?;
81
82 client
83 .get(url)
84 .send()
85 .await
86 .context("failed to fetch DID document")?
87 .json()
88 .await
89 .context("failed to decode DID document")
90}