A library for ATProtocol identities.
1//! Data structures for DID documents and AT Protocol entities. 2//! 3//! Core data models for AT Protocol identity including DID documents, services, 4//! and verification methods with full JSON serialization support. 5 6use serde::{Deserialize, Serialize}; 7use serde_json::Value; 8use std::collections::HashMap; 9 10/// AT Protocol service configuration from a DID document. 11/// Represents services like Personal Data Servers (PDS). 12#[cfg_attr(debug_assertions, derive(Debug))] 13#[derive(Clone, Serialize, Deserialize, PartialEq)] 14#[serde(rename_all = "camelCase")] 15pub struct Service { 16 /// Unique identifier for the service. 17 pub id: String, 18 /// Service type (e.g., "AtprotoPersonalDataServer"). 19 pub r#type: String, 20 /// URL endpoint where the service can be reached. 21 pub service_endpoint: String, 22 23 /// Additional service properties not explicitly defined. 24 #[serde(flatten)] 25 pub extra: HashMap<String, Value>, 26} 27 28/// Cryptographic verification method from a DID document. 29/// Used to verify signatures and authenticate identity operations. 30#[cfg_attr(debug_assertions, derive(Debug))] 31#[derive(Clone, Serialize, Deserialize, PartialEq)] 32#[serde(tag = "type")] 33pub enum VerificationMethod { 34 /// Multikey verification method with multibase-encoded public key. 35 Multikey { 36 /// Unique identifier for this verification method. 37 id: String, 38 /// DID that controls this verification method. 39 controller: String, 40 41 /// Public key encoded in multibase format. 42 #[serde(rename = "publicKeyMultibase")] 43 public_key_multibase: String, 44 45 /// Additional verification method properties. 46 #[serde(flatten)] 47 extra: HashMap<String, Value>, 48 }, 49 50 /// Other verification method types not explicitly supported. 51 #[serde(untagged)] 52 Other { 53 /// All properties of the unsupported verification method. 54 #[serde(flatten)] 55 extra: HashMap<String, Value>, 56 }, 57} 58 59/// Complete DID document containing identity information. 60/// Contains services, verification methods, and aliases for a DID. 61#[cfg_attr(debug_assertions, derive(Debug))] 62#[derive(Clone, Serialize, Deserialize, PartialEq)] 63#[serde(rename_all = "camelCase")] 64pub struct Document { 65 /// JSON-LD context URLs defining the semantics of the DID document. 66 /// Typically includes "https://www.w3.org/ns/did/v1" and method-specific contexts. 67 #[serde(rename = "@context", default)] 68 pub context: Vec<String>, 69 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. 73 #[serde(default)] 74 pub also_known_as: Vec<String>, 75 /// Available services for this identity. 76 #[serde(default)] 77 pub service: Vec<Service>, 78 79 /// Cryptographic verification methods. 80 #[serde(alias = "verificationMethod", default)] 81 pub verification_method: Vec<VerificationMethod>, 82 83 /// Additional document properties not explicitly defined. 84 #[serde(flatten)] 85 pub extra: HashMap<String, Value>, 86} 87 88/// Builder for constructing DID documents with a fluent API. 89/// Provides controlled construction with validation capabilities. 90#[derive(Default)] 91pub struct DocumentBuilder { 92 context: Option<Vec<String>>, 93 id: Option<String>, 94 also_known_as: Vec<String>, 95 service: Vec<Service>, 96 verification_method: Vec<VerificationMethod>, 97 extra: HashMap<String, Value>, 98} 99 100impl DocumentBuilder { 101 /// Creates a new DocumentBuilder with empty fields. 102 pub fn new() -> Self { 103 Self::default() 104 } 105 106 /// Sets the JSON-LD context URLs for the document. 107 pub fn context(mut self, context: Vec<String>) -> Self { 108 self.context = Some(context); 109 self 110 } 111 112 /// Adds a single context URL to the document. 113 pub fn add_context(mut self, context_url: impl Into<String>) -> Self { 114 self.context 115 .get_or_insert_with(|| vec!["https://www.w3.org/ns/did/v1".to_string()]) 116 .push(context_url.into()); 117 self 118 } 119 120 /// Sets the DID identifier for the document. 121 pub fn id(mut self, id: impl Into<String>) -> Self { 122 self.id = Some(id.into()); 123 self 124 } 125 126 /// Sets all alternative identifiers at once. 127 pub fn also_known_as(mut self, aliases: Vec<String>) -> Self { 128 self.also_known_as = aliases; 129 self 130 } 131 132 /// Adds a single alternative identifier. 133 pub fn add_also_known_as(mut self, alias: impl Into<String>) -> Self { 134 self.also_known_as.push(alias.into()); 135 self 136 } 137 138 /// Sets all services at once. 139 pub fn services(mut self, services: Vec<Service>) -> Self { 140 self.service = services; 141 self 142 } 143 144 /// Adds a single service to the document. 145 pub fn add_service(mut self, service: Service) -> Self { 146 self.service.push(service); 147 self 148 } 149 150 /// Convenience method to add a PDS service. 151 pub fn add_pds_service(mut self, endpoint: impl Into<String>) -> Self { 152 self.service.push(Service { 153 id: "#atproto_pds".to_string(), 154 r#type: "AtprotoPersonalDataServer".to_string(), 155 service_endpoint: endpoint.into(), 156 extra: HashMap::new(), 157 }); 158 self 159 } 160 161 /// Sets all verification methods at once. 162 pub fn verification_methods(mut self, methods: Vec<VerificationMethod>) -> Self { 163 self.verification_method = methods; 164 self 165 } 166 167 /// Adds a single verification method. 168 pub fn add_verification_method(mut self, method: VerificationMethod) -> Self { 169 self.verification_method.push(method); 170 self 171 } 172 173 /// Convenience method to add a Multikey verification method. 174 pub fn add_multikey( 175 mut self, 176 id: impl Into<String>, 177 controller: impl Into<String>, 178 public_key_multibase: impl Into<String>, 179 ) -> Self { 180 let key_multibase = public_key_multibase.into(); 181 let key_multibase = key_multibase 182 .strip_prefix("did:key:") 183 .unwrap_or(&key_multibase) 184 .to_string(); 185 186 self.verification_method.push(VerificationMethod::Multikey { 187 id: id.into(), 188 controller: controller.into(), 189 public_key_multibase: key_multibase, 190 extra: HashMap::new(), 191 }); 192 self 193 } 194 195 /// Adds an extra property to the document. 196 pub fn add_extra(mut self, key: impl Into<String>, value: Value) -> Self { 197 self.extra.insert(key.into(), value); 198 self 199 } 200 201 /// Builds the Document, returning an error if required fields are missing. 202 pub fn build(self) -> Result<Document, &'static str> { 203 let id = self.id.ok_or("Document ID is required")?; 204 205 // Use default context if not provided 206 let context = self 207 .context 208 .unwrap_or_else(|| vec!["https://www.w3.org/ns/did/v1".to_string()]); 209 210 Ok(Document { 211 context, 212 id, 213 also_known_as: self.also_known_as, 214 service: self.service, 215 verification_method: self.verification_method, 216 extra: self.extra, 217 }) 218 } 219} 220 221impl Document { 222 /// Creates a new DocumentBuilder for constructing a Document. 223 pub fn builder() -> DocumentBuilder { 224 DocumentBuilder::new() 225 } 226 227 /// Extracts Personal Data Server endpoints from services. 228 /// Returns URLs of all AtprotoPersonalDataServer services. 229 pub fn pds_endpoints(&self) -> Vec<&str> { 230 self.service 231 .iter() 232 .filter_map(|service| { 233 if service.r#type == "AtprotoPersonalDataServer" { 234 Some(service.service_endpoint.as_str()) 235 } else { 236 None 237 } 238 }) 239 .collect() 240 } 241 242 /// Gets the primary handle from alsoKnownAs aliases. 243 /// Returns the first alias with "at://" prefix stripped if present. 244 pub fn handles(&self) -> Option<&str> { 245 self.also_known_as.first().map(|handle| { 246 if let Some(trimmed) = handle.strip_prefix("at://") { 247 trimmed 248 } else { 249 handle.as_str() 250 } 251 }) 252 } 253 254 /// Extracts multibase public keys from verification methods. 255 /// Returns public keys from Multikey verification methods only. 256 pub fn did_keys(&self) -> Vec<&str> { 257 self.verification_method 258 .iter() 259 .filter_map(|verification_method| match verification_method { 260 VerificationMethod::Multikey { 261 public_key_multibase, 262 .. 263 } => Some(public_key_multibase.as_str()), 264 VerificationMethod::Other { extra: _ } => None, 265 }) 266 .collect() 267 } 268} 269 270/// Resolved handle information linking DID to human-readable identifier. 271/// Contains the complete identity resolution result. 272#[cfg_attr(debug_assertions, derive(Debug))] 273#[derive(Clone, Deserialize, Serialize)] 274pub struct Handle { 275 /// The resolved DID identifier. 276 pub did: String, 277 /// Human-readable handle (e.g., "alice.bsky.social"). 278 pub handle: String, 279 /// Personal Data Server URL hosting the identity. 280 pub pds: String, 281 /// Available cryptographic verification methods. 282 pub verification_methods: Vec<String>, 283} 284 285#[cfg(test)] 286mod tests { 287 use crate::model::{Document, Service}; 288 use std::collections::HashMap; 289 290 #[test] 291 fn test_deserialize() { 292 let document = serde_json::from_str::<Document>( 293 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Multikey","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##, 294 ); 295 assert!(document.is_ok()); 296 297 let document = document.unwrap(); 298 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 299 } 300 301 #[test] 302 fn test_document_builder() { 303 // Test basic builder 304 let doc = Document::builder() 305 .id("did:plc:test123") 306 .build() 307 .expect("Should build with just ID"); 308 309 assert_eq!(doc.id, "did:plc:test123"); 310 assert_eq!(doc.context, vec!["https://www.w3.org/ns/did/v1"]); 311 assert!(doc.also_known_as.is_empty()); 312 assert!(doc.service.is_empty()); 313 assert!(doc.verification_method.is_empty()); 314 } 315 316 #[test] 317 fn test_document_builder_full() { 318 let doc = Document::builder() 319 .id("did:plc:test123") 320 .add_context("https://w3id.org/security/multikey/v1") 321 .add_also_known_as("at://test.bsky.social") 322 .add_also_known_as("https://test.example.com") 323 .add_pds_service("https://pds.example.com") 324 .add_multikey( 325 "did:plc:test123#atproto", 326 "did:plc:test123", 327 "zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF", 328 ) 329 .build() 330 .expect("Should build complete document"); 331 332 assert_eq!(doc.id, "did:plc:test123"); 333 assert_eq!(doc.context.len(), 2); 334 assert_eq!(doc.also_known_as.len(), 2); 335 assert_eq!(doc.service.len(), 1); 336 assert_eq!(doc.service[0].r#type, "AtprotoPersonalDataServer"); 337 assert_eq!(doc.verification_method.len(), 1); 338 339 // Test PDS endpoint extraction 340 let pds_endpoints = doc.pds_endpoints(); 341 assert_eq!(pds_endpoints.len(), 1); 342 assert_eq!(pds_endpoints[0], "https://pds.example.com"); 343 } 344 345 #[test] 346 fn test_document_builder_with_service() { 347 let service = Service { 348 id: "#custom".to_string(), 349 r#type: "CustomService".to_string(), 350 service_endpoint: "https://custom.example.com".to_string(), 351 extra: HashMap::new(), 352 }; 353 354 let doc = Document::builder() 355 .id("did:web:example.com") 356 .add_service(service) 357 .build() 358 .expect("Should build with custom service"); 359 360 assert_eq!(doc.service.len(), 1); 361 assert_eq!(doc.service[0].r#type, "CustomService"); 362 } 363 364 #[test] 365 fn test_document_builder_missing_id() { 366 let result = Document::builder() 367 .add_also_known_as("at://test.bsky.social") 368 .build(); 369 370 assert!(result.is_err()); 371 assert_eq!(result.unwrap_err(), "Document ID is required"); 372 } 373 374 #[test] 375 fn test_document_builder_with_extra() { 376 let doc = Document::builder() 377 .id("did:plc:test123") 378 .add_extra("customField", serde_json::json!("customValue")) 379 .add_extra("numberField", serde_json::json!(42)) 380 .build() 381 .expect("Should build with extra fields"); 382 383 assert_eq!(doc.extra.len(), 2); 384 assert_eq!( 385 doc.extra.get("customField").unwrap(), 386 &serde_json::json!("customValue") 387 ); 388 assert_eq!( 389 doc.extra.get("numberField").unwrap(), 390 &serde_json::json!(42) 391 ); 392 } 393 394 #[test] 395 fn test_deserialize_unsupported_verification_method() { 396 let documents = vec![ 397 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Ed25519VerificationKey2020","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##, 398 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id": "did:example:123#_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A","type": "JsonWebKey2020","controller": "did:example:123","publicKeyJwk": {"crv": "Ed25519","x": "VCpo2LMLhn6iWku8MKvSLg2ZAoC-nlOyPVQaO3FxVeQ","kty": "OKP","kid": "_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A"}}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##, 399 ]; 400 for document in documents { 401 let document = serde_json::from_str::<Document>(document); 402 assert!(document.is_ok()); 403 404 let document = document.unwrap(); 405 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 406 } 407 } 408 409 #[test] 410 fn test_deserialize_service_did_document() { 411 // DID document from api.bsky.app - a service DID without alsoKnownAs 412 let document = serde_json::from_str::<Document>( 413 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##, 414 ); 415 assert!(document.is_ok(), "Failed to parse: {:?}", document.err()); 416 417 let document = document.unwrap(); 418 assert_eq!(document.id, "did:web:api.bsky.app"); 419 assert!(document.also_known_as.is_empty()); 420 assert_eq!(document.service.len(), 2); 421 assert_eq!(document.service[0].id, "#bsky_notif"); 422 assert_eq!(document.service[1].id, "#bsky_appview"); 423 } 424}