Skip to main content

didbot_identity/
document.rs

1//! The DID document that backs an agent account.
2//!
3//! A document says where an account is and who signs for it. atproto reads
4//! the `id`, the `alsoKnownAs` handle, the `#atproto` signing key and the
5//! `#atproto_pds` service entry. Beside those, every hosted account names
6//! its `operator`: the human who answers for it, one read away from any
7//! depth of the tree. How a process logs in as the account is not in the
8//! document at all; that is the account's `bot.did.credential` records.
9
10use std::collections::BTreeSet;
11
12use serde::{Deserialize, Serialize};
13
14use crate::did::AccountDid;
15
16/// JSON-LD contexts a spec-compliant atproto DID document declares.
17pub const DEFAULT_CONTEXT: [&str; 3] = [
18    "https://www.w3.org/ns/did/v1",
19    "https://w3id.org/security/multikey/v1",
20    "https://w3id.org/security/suites/secp256k1-2019/v1",
21];
22
23/// Fragment identifying the atproto signing key within a DID document.
24pub const ATPROTO_KEY_FRAGMENT: &str = "#atproto";
25
26/// Fragment identifying the account's PDS within a DID document.
27pub const ATPROTO_PDS_FRAGMENT: &str = "#atproto_pds";
28
29/// The `type` atproto requires on the signing key's verification method.
30pub const MULTIKEY_TYPE: &str = "Multikey";
31
32/// The `type` atproto requires on the PDS service entry.
33pub const PDS_SERVICE_TYPE: &str = "AtprotoPersonalDataServer";
34
35/// The property naming the human who answers for a hosted account. Not a
36/// DID Core property: a didbot deployment writes it, and a reader that does
37/// not know it ignores it, which is what every atproto resolver does.
38pub const OPERATOR_PROPERTY: &str = "operator";
39
40/// A DID document, reduced to the fields this crate consumes.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct DidDocument {
44    /// JSON-LD contexts. Serialized first, under the literal name
45    /// `@context`.
46    #[serde(rename = "@context")]
47    pub context: Vec<String>,
48    /// The DID this document describes. A resolver must check it against the
49    /// DID it asked for; see [`mod@crate::resolve`].
50    pub id: String,
51    /// Claimed handles, as `at://` URIs. Ordered: the first syntactically
52    /// valid entry is the claimed handle.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub also_known_as: Vec<String>,
55    /// Public keys. The atproto signing key is the entry whose `id` ends in
56    /// `#atproto`.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub verification_method: Vec<VerificationMethod>,
59    /// DID Core's `authentication`, read from other documents and never
60    /// written into this deployment's own.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub authentication: Vec<String>,
63    /// Service endpoints. The PDS is the entry whose `id` ends in
64    /// `#atproto_pds`.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub service: Vec<Service>,
67    /// The DID of the human who answers for this account, from the
68    /// document's [`OPERATOR_PROPERTY`]. The same fact as the account's
69    /// `bot.did.registration` names, published here so one HTTP GET answers
70    /// it.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub operator: Option<String>,
73}
74
75/// One public key in a DID document.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct VerificationMethod {
79    /// Fully qualified or relative fragment, e.g. `did:web:x.example#atproto`.
80    pub id: String,
81    /// Always [`MULTIKEY_TYPE`] for the atproto signing key.
82    #[serde(rename = "type")]
83    pub type_: String,
84    /// The DID that controls this key.
85    pub controller: String,
86    /// The public key, multibase-encoded.
87    pub public_key_multibase: String,
88}
89
90/// One service endpoint in a DID document.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct Service {
94    /// Fragment naming the service, e.g. `#atproto_pds`.
95    pub id: String,
96    /// Always [`PDS_SERVICE_TYPE`] for a personal data server.
97    #[serde(rename = "type")]
98    pub type_: String,
99    /// Scheme, hostname and optional port. No path, query or fragment.
100    pub service_endpoint: String,
101}
102
103/// The OpenID Connect identity an account is.
104///
105/// An ID token from `issuer` whose claims carry every entry of `claims`,
106/// each with the value given, authenticates as the account.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct OidcIdentity {
109    /// The issuer, as the `iss` claim spells it.
110    pub issuer: String,
111    /// Claims the token must carry, in the order given.
112    pub claims: Vec<Claim>,
113}
114
115/// One claim an ID token must carry to be this account.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Claim {
118    /// The claim's name, e.g. `sub`.
119    pub name: String,
120    /// The value the claim must hold.
121    pub value: String,
122}
123
124impl Claim {
125    /// The claim this names in an ID token, as a JSON pointer (RFC 6901)
126    /// spelled one way.
127    ///
128    /// A name beginning with `/` is a pointer into the token's claims,
129    /// unescaped the way `serde_json::Value::pointer` reads one. Any other
130    /// name is one top-level claim. Every spelling of one claim gives the
131    /// same pointer: `repository` and `/repository` are both `/repository`.
132    #[must_use]
133    pub fn pointer(&self) -> String {
134        let tokens: Vec<String> = match self.name.strip_prefix('/') {
135            Some(pointer) => pointer
136                .split('/')
137                .map(|token| token.replace("~1", "/").replace("~0", "~"))
138                .collect(),
139            None => vec![self.name.clone()],
140        };
141        tokens
142            .iter()
143            .map(|token| format!("/{}", token.replace('~', "~0").replace('/', "~1")))
144            .collect()
145    }
146}
147
148impl OidcIdentity {
149    /// An identity at `issuer` matched by every `(name, value)` claim given.
150    pub fn new<I, N, V>(issuer: &str, claims: I) -> Self
151    where
152        I: IntoIterator<Item = (N, V)>,
153        N: Into<String>,
154        V: Into<String>,
155    {
156        Self {
157            issuer: issuer.to_owned(),
158            claims: claims
159                .into_iter()
160                .map(|(name, value)| Claim {
161                    name: name.into(),
162                    value: value.into(),
163                })
164                .collect(),
165        }
166    }
167
168    /// Every claim this identity names, by its [`Claim::pointer`], with the
169    /// value it must hold. A claim named twice with one value is here once.
170    #[must_use]
171    pub fn claims_by_pointer(&self) -> BTreeSet<(String, &str)> {
172        self.claims
173            .iter()
174            .map(|claim| (claim.pointer(), claim.value.as_str()))
175            .collect()
176    }
177
178    /// The first claim whose [`Claim::pointer`] an earlier claim already
179    /// names, in either spelling.
180    #[must_use]
181    pub fn repeated_claim(&self) -> Option<&Claim> {
182        let mut named = BTreeSet::new();
183        self.claims
184            .iter()
185            .find(|claim| !named.insert(claim.pointer()))
186    }
187}
188
189impl DidDocument {
190    /// Builds the document for an agent account.
191    ///
192    /// `public_key_multibase` is a `&str` on purpose: this crate has no
193    /// opinion on how the key was generated, and taking a key type here would
194    /// make every consumer of DID documents depend on the key crate.
195    ///
196    /// Fragment ids are emitted fully qualified. The spec asks readers to
197    /// accept both forms, and the qualified one survives being copied out of
198    /// the document into some other context without losing its subject.
199    pub fn for_account(
200        did: &AccountDid,
201        public_key_multibase: &str,
202        handle: &str,
203        pds_endpoint: &str,
204    ) -> Self {
205        let did_str = did.as_str();
206        Self {
207            context: DEFAULT_CONTEXT.iter().map(|c| (*c).to_owned()).collect(),
208            id: did_str.to_owned(),
209            also_known_as: vec![format!("at://{handle}")],
210            verification_method: vec![VerificationMethod {
211                id: format!("{did_str}{ATPROTO_KEY_FRAGMENT}"),
212                type_: MULTIKEY_TYPE.to_owned(),
213                controller: did_str.to_owned(),
214                public_key_multibase: public_key_multibase.to_owned(),
215            }],
216            authentication: Vec::new(),
217            service: vec![Service {
218                id: format!("{did_str}{ATPROTO_PDS_FRAGMENT}"),
219                type_: PDS_SERVICE_TYPE.to_owned(),
220                service_endpoint: pds_endpoint.to_owned(),
221            }],
222            operator: None,
223        }
224    }
225
226    /// Builds the document for the server itself.
227    ///
228    /// A service document, not an account one: no signing key and no handle,
229    /// because the server holds neither on its own behalf. It exists so that
230    /// the DID a vouch record names resolves to something that says where the
231    /// server is, which is the whole of what a reader needs from it.
232    pub fn for_service(did: &str, pds_endpoint: &str) -> Self {
233        Self {
234            context: DEFAULT_CONTEXT.iter().map(|c| (*c).to_owned()).collect(),
235            id: did.to_owned(),
236            also_known_as: Vec::new(),
237            verification_method: Vec::new(),
238            authentication: Vec::new(),
239            service: vec![Service {
240                id: format!("{did}{ATPROTO_PDS_FRAGMENT}"),
241                type_: PDS_SERVICE_TYPE.to_owned(),
242                service_endpoint: pds_endpoint.to_owned(),
243            }],
244            operator: None,
245        }
246    }
247
248    /// The first claimed handle, with the `at://` prefix stripped.
249    ///
250    /// "First *valid* entry" is the spec's rule and now the implementation's:
251    /// `alsoKnownAs` is an ordered list a DID controller writes, it may hold
252    /// entries in other URI schemes, and it is not this crate's to trust. An
253    /// entry that is not a syntactically valid handle is skipped rather than
254    /// returned, so a document whose first entry is `at://not a handle` and
255    /// whose second is real resolves to the real one instead of to garbage a
256    /// caller would then try to look up.
257    pub fn handle(&self) -> Option<&str> {
258        self.also_known_as
259            .iter()
260            .filter_map(|aka| aka.strip_prefix("at://"))
261            .find(|handle| crate::handle::validate(handle).is_ok())
262    }
263
264    /// Whether this document claims `handle` as its own.
265    ///
266    /// The other half of atproto's bidirectional rule. A handle resolves to a
267    /// DID, and that is only half an answer: the DID's document has to claim
268    /// the handle back, or what has been resolved is somebody asserting a name
269    /// they do not hold. A resolver that stops after the first half will file
270    /// records under whichever account was quickest to claim the name.
271    ///
272    /// Only the *first valid* `alsoKnownAs` entry counts, which is [`handle`]
273    /// and the specification's own rule. Deliberately not "any entry mentions
274    /// it": `alsoKnownAs` is an ordered list its controller writes, so a
275    /// document could list a real handle second and have this answer yes to a
276    /// name no resolver would ever return for it.
277    ///
278    /// Compared case-insensitively, because a handle is a hostname.
279    ///
280    /// [`handle`]: Self::handle
281    pub fn claims_handle(&self, handle: &str) -> bool {
282        self.handle()
283            .is_some_and(|claimed| claimed.eq_ignore_ascii_case(handle))
284    }
285
286    /// Whether `id` is this document's own `fragment` entry.
287    ///
288    /// A DID URL is a DID plus a fragment, and the DID half is what says
289    /// whose entry it is. `ends_with` alone does not read it, so a document
290    /// for `did:web:victim.example` could list
291    /// `did:web:attacker.example#atproto` and have it answered as the
292    /// victim's signing key — a well-formed entry that does not belong to
293    /// the subject, accepted because the only field that says so was never
294    /// looked at. The relative form (`#atproto`, no DID at all) is the other
295    /// spelling the spec asks readers to accept, and it can only mean the
296    /// document's own subject.
297    fn owns_fragment(&self, id: &str, fragment: &str) -> bool {
298        owns_fragment(&self.id, id, fragment)
299    }
300
301    /// Names the human who answers for this account.
302    #[must_use]
303    pub fn with_operator(mut self, operator: impl Into<String>) -> Self {
304        self.operator = Some(operator.into());
305        self
306    }
307
308    /// The human who answers for this account, if the document says.
309    pub fn operator(&self) -> Option<&str> {
310        self.operator.as_deref()
311    }
312
313    /// The multibase signing key, taking the first `#atproto` entry of the
314    /// correct type and ignoring any others, as the spec directs.
315    ///
316    /// The entry must also be this document's own: an `id` whose DID half is
317    /// this document's `id`, or absent entirely.
318    pub fn signing_key_multibase(&self) -> Option<&str> {
319        self.verification_method
320            .iter()
321            .find(|vm| {
322                self.owns_fragment(&vm.id, ATPROTO_KEY_FRAGMENT) && vm.type_ == MULTIKEY_TYPE
323            })
324            .map(|vm| vm.public_key_multibase.as_str())
325    }
326
327    /// The PDS endpoint, taking the first `#atproto_pds` entry of the correct
328    /// type and ignoring any others.
329    ///
330    /// The entry must also be this document's own: an `id` whose DID half is
331    /// this document's `id`, or absent entirely.
332    pub fn pds_endpoint(&self) -> Option<&str> {
333        self.service
334            .iter()
335            .find(|s| {
336                self.owns_fragment(&s.id, ATPROTO_PDS_FRAGMENT) && s.type_ == PDS_SERVICE_TYPE
337            })
338            .map(|s| s.service_endpoint.as_str())
339    }
340
341    /// The endpoint of the service whose id is `#service`, of whatever
342    /// type: the entry an `atproto-proxy` header names, as
343    /// `did:web:api.bsky.app#bsky_appview` names `bsky_appview`.
344    ///
345    /// The entry must be this document's own, as for [`Self::pds_endpoint`].
346    pub fn service_endpoint(&self, service: &str) -> Option<&str> {
347        let fragment = format!("#{service}");
348        self.service
349            .iter()
350            .find(|s| self.owns_fragment(&s.id, &fragment))
351            .map(|s| s.service_endpoint.as_str())
352    }
353}
354
355/// Whether `id` is `subject`'s own `fragment` entry; see
356/// [`DidDocument::owns_fragment`].
357fn owns_fragment(subject: &str, id: &str, fragment: &str) -> bool {
358    matches!(id.strip_suffix(fragment), Some(base) if base.is_empty() || base == subject)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn claim(name: &str) -> Claim {
366        Claim {
367            name: name.to_owned(),
368            value: "x".to_owned(),
369        }
370    }
371
372    /// A top-level name and the pointer to it are one claim, and so are two
373    /// spellings of one pointer. A pointer into a nested claim is not a
374    /// top-level name with a slash in it.
375    #[test]
376    fn every_spelling_of_one_claim_is_one_pointer() {
377        for (one, other) in [
378            ("repository", "/repository"),
379            ("kubernetes.io/namespace", "/kubernetes.io~1namespace"),
380            ("a~b", "/a~0b"),
381            ("/a~2b", "/a~02b"),
382        ] {
383            assert_eq!(
384                claim(one).pointer(),
385                claim(other).pointer(),
386                "{one}, {other}"
387            );
388        }
389        assert_ne!(
390            claim("kubernetes.io/namespace").pointer(),
391            claim("/kubernetes.io/namespace").pointer()
392        );
393        let identity = OidcIdentity {
394            issuer: "https://issuer.example".to_owned(),
395            claims: vec![claim("repository"), claim("sub"), claim("/repository")],
396        };
397        assert_eq!(identity.repeated_claim(), Some(&claim("/repository")));
398        assert_eq!(identity.claims_by_pointer().len(), 2);
399    }
400}