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}