High-performance implementation of plcbundle written in Rust
at main 459 lines 15 kB view raw
1//! DID resolution: build PLC DID state and convert to W3C DID Documents; handles legacy fields and endpoint normalization 2// DID Resolution - Convert PLC operations to W3C DID Documents 3use crate::operations::Operation; 4use anyhow::Result; 5use serde::{Deserialize, Serialize}; 6use sonic_rs::{JsonContainerTrait, JsonValueTrait, Value}; 7use std::collections::HashMap; 8 9// ============================================================================ 10// DID State (PLC-specific format) 11// ============================================================================ 12 13#[derive(Debug, Clone, Serialize, Deserialize)] 14pub struct DIDState { 15 pub did: String, 16 #[serde(rename = "rotationKeys")] 17 pub rotation_keys: Vec<String>, 18 #[serde(rename = "verificationMethods")] 19 pub verification_methods: HashMap<String, String>, 20 #[serde(rename = "alsoKnownAs")] 21 pub also_known_as: Vec<String>, 22 pub services: HashMap<String, ServiceDefinition>, 23} 24 25#[derive(Debug, Clone, Serialize, Deserialize)] 26pub struct ServiceDefinition { 27 #[serde(rename = "type")] 28 pub service_type: String, 29 pub endpoint: String, 30} 31 32// ============================================================================ 33// W3C DID Document 34// ============================================================================ 35 36#[derive(Debug, Clone, Serialize, Deserialize)] 37pub struct DIDDocument { 38 #[serde(rename = "@context")] 39 pub context: Vec<String>, 40 pub id: String, 41 #[serde(rename = "alsoKnownAs")] 42 pub also_known_as: Vec<String>, 43 #[serde(rename = "verificationMethod")] 44 pub verification_method: Vec<VerificationMethod>, 45 #[serde(skip_serializing_if = "Vec::is_empty")] 46 pub service: Vec<Service>, 47} 48 49#[derive(Debug, Clone, Serialize, Deserialize)] 50pub struct VerificationMethod { 51 pub id: String, 52 #[serde(rename = "type")] 53 pub key_type: String, 54 pub controller: String, 55 #[serde(rename = "publicKeyMultibase")] 56 pub public_key_multibase: String, 57} 58 59#[derive(Debug, Clone, Serialize, Deserialize)] 60pub struct Service { 61 pub id: String, 62 #[serde(rename = "type")] 63 pub service_type: String, 64 #[serde(rename = "serviceEndpoint")] 65 pub service_endpoint: String, 66} 67 68// ============================================================================ 69// Resolution Functions 70// ============================================================================ 71 72/// Resolve DID to W3C DID Document from operations 73pub fn resolve_did_document(did: &str, operations: &[Operation]) -> Result<DIDDocument> { 74 if operations.is_empty() { 75 anyhow::bail!("no operations found for DID"); 76 } 77 78 // Build current state from operations 79 let state = build_did_state(did, operations)?; 80 81 // Convert to DID document format 82 Ok(state_to_did_document(&state)) 83} 84 85/// Build DID state by applying operations in order 86pub fn build_did_state(did: &str, operations: &[Operation]) -> Result<DIDState> { 87 let mut state: Option<DIDState> = None; 88 89 for op in operations { 90 // Skip nullified operations 91 if op.nullified { 92 continue; 93 } 94 95 // Check operation type 96 if let Some(op_type) = op.operation.get("type").and_then(|v| v.as_str()) { 97 // Handle tombstone (deactivated DID) 98 if op_type == "plc_tombstone" { 99 anyhow::bail!("DID has been deactivated"); 100 } 101 } 102 103 // Initialize state on first operation 104 if state.is_none() { 105 state = Some(DIDState { 106 did: did.to_string(), 107 rotation_keys: Vec::new(), 108 verification_methods: HashMap::new(), 109 also_known_as: Vec::new(), 110 services: HashMap::new(), 111 }); 112 } 113 114 // Apply operation to state 115 apply_operation_to_state(state.as_mut().unwrap(), &op.operation); 116 } 117 118 state.ok_or_else(|| anyhow::anyhow!("no valid operations found")) 119} 120 121/// Apply a single operation to the state 122fn apply_operation_to_state(state: &mut DIDState, op_data: &Value) { 123 // Update rotation keys 124 if let Some(rot_keys) = op_data.get("rotationKeys").and_then(|v| v.as_array()) { 125 state.rotation_keys = rot_keys 126 .iter() 127 .filter_map(|v| v.as_str().map(String::from)) 128 .collect(); 129 } 130 131 // Update verification methods 132 if let Some(vm) = op_data 133 .get("verificationMethods") 134 .and_then(|v| v.as_object()) 135 { 136 state.verification_methods = vm 137 .iter() 138 .filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string()))) 139 .collect(); 140 } 141 142 // Handle legacy signingKey format 143 if let Some(signing_key) = op_data.get("signingKey").and_then(|v| v.as_str()) { 144 state 145 .verification_methods 146 .insert("atproto".to_string(), signing_key.to_string()); 147 } 148 149 // Update alsoKnownAs 150 if let Some(aka) = op_data.get("alsoKnownAs").and_then(|v| v.as_array()) { 151 state.also_known_as = aka 152 .iter() 153 .filter_map(|v| v.as_str().map(String::from)) 154 .collect(); 155 } 156 157 // Handle legacy handle format 158 if let Some(handle) = op_data.get("handle").and_then(|v| v.as_str()) 159 && state.also_known_as.is_empty() 160 { 161 state.also_known_as = vec![format!("at://{}", handle)]; 162 } 163 164 // Update services 165 if let Some(services) = op_data.get("services").and_then(|v| v.as_object()) { 166 state.services = services 167 .iter() 168 .filter_map(|(k, v)| { 169 let service_type = v.get("type")?.as_str()?.to_string(); 170 let endpoint = v.get("endpoint")?.as_str()?.to_string(); 171 Some(( 172 k.to_string(), 173 ServiceDefinition { 174 service_type, 175 endpoint: normalize_service_endpoint(&endpoint), 176 }, 177 )) 178 }) 179 .collect(); 180 } 181 182 // Handle legacy service format 183 if let Some(service) = op_data.get("service").and_then(|v| v.as_str()) { 184 state.services.insert( 185 "atproto_pds".to_string(), 186 ServiceDefinition { 187 service_type: "AtprotoPersonalDataServer".to_string(), 188 endpoint: normalize_service_endpoint(service), 189 }, 190 ); 191 } 192} 193 194/// Convert PLC state to W3C DID Document 195fn state_to_did_document(state: &DIDState) -> DIDDocument { 196 // Base contexts - always include multikey 197 let mut contexts = vec![ 198 "https://www.w3.org/ns/did/v1".to_string(), 199 "https://w3id.org/security/multikey/v1".to_string(), 200 ]; 201 202 let mut has_secp256k1 = false; 203 let mut has_p256 = false; 204 205 // Check verification method key types 206 for did_key in state.verification_methods.values() { 207 match detect_key_type(did_key) { 208 KeyType::Secp256k1 => has_secp256k1 = true, 209 KeyType::P256 => has_p256 = true, 210 _ => {} 211 } 212 } 213 214 // Add suite-specific contexts 215 if has_secp256k1 { 216 contexts.push("https://w3id.org/security/suites/secp256k1-2019/v1".to_string()); 217 } 218 if has_p256 { 219 contexts.push("https://w3id.org/security/suites/ecdsa-2019/v1".to_string()); 220 } 221 222 // Convert services 223 let services = state 224 .services 225 .iter() 226 .map(|(id, svc)| Service { 227 id: format!("#{}", id), 228 service_type: svc.service_type.clone(), 229 service_endpoint: svc.endpoint.clone(), 230 }) 231 .collect(); 232 233 // Convert verification methods 234 let verification_methods = state 235 .verification_methods 236 .iter() 237 .map(|(id, did_key)| VerificationMethod { 238 id: format!("{}#{}", state.did, id), 239 key_type: "Multikey".to_string(), 240 controller: state.did.clone(), 241 public_key_multibase: extract_multibase_from_did_key(did_key), 242 }) 243 .collect(); 244 245 DIDDocument { 246 context: contexts, 247 id: state.did.clone(), 248 also_known_as: state.also_known_as.clone(), 249 verification_method: verification_methods, 250 service: services, 251 } 252} 253 254// ============================================================================ 255// Helper Functions 256// ============================================================================ 257 258#[derive(Debug, PartialEq)] 259enum KeyType { 260 Secp256k1, 261 P256, 262 Ed25519, 263 Unknown, 264} 265 266fn detect_key_type(did_key: &str) -> KeyType { 267 let multibase = extract_multibase_from_did_key(did_key); 268 269 if multibase.len() < 3 { 270 return KeyType::Unknown; 271 } 272 273 // The 'z' is base58btc multibase prefix, check next characters 274 match &multibase[1..3] { 275 "Q3" => KeyType::Secp256k1, // zQ3s... 276 "Dn" => KeyType::P256, // zDn... 277 "6M" => KeyType::Ed25519, // z6Mk... 278 _ => KeyType::Unknown, 279 } 280} 281 282fn extract_multibase_from_did_key(did_key: &str) -> String { 283 did_key 284 .strip_prefix("did:key:") 285 .unwrap_or(did_key) 286 .to_string() 287} 288 289fn normalize_service_endpoint(endpoint: &str) -> String { 290 if endpoint.starts_with("http://") || endpoint.starts_with("https://") { 291 endpoint.to_string() 292 } else { 293 format!("https://{}", endpoint) 294 } 295} 296 297// ============================================================================ 298// Validation 299// ============================================================================ 300 301pub fn validate_did_format(did: &str) -> Result<()> { 302 if !did.starts_with("did:plc:") { 303 anyhow::bail!("invalid DID method: must start with 'did:plc:'"); 304 } 305 306 if did.len() != 32 { 307 anyhow::bail!("invalid DID length: expected 32 chars, got {}", did.len()); 308 } 309 310 // Validate identifier part (24 chars, base32 alphabet) 311 let identifier = &did[8..]; 312 if identifier.len() != 24 { 313 anyhow::bail!( 314 "invalid identifier length: expected 24 chars, got {}", 315 identifier.len() 316 ); 317 } 318 319 // Check base32 alphabet (a-z, 2-7) 320 for c in identifier.chars() { 321 if !matches!(c, 'a'..='z' | '2'..='7') { 322 anyhow::bail!( 323 "invalid character in identifier: {} (must be base32: a-z, 2-7)", 324 c 325 ); 326 } 327 } 328 329 Ok(()) 330} 331 332// ============================================================================ 333// Audit Log Formatting 334// ============================================================================ 335 336#[derive(Debug, Clone, Serialize, Deserialize)] 337pub struct AuditLogEntry { 338 pub did: String, 339 #[serde(skip)] 340 pub operation: Value, 341 pub cid: Option<String>, 342 #[serde(skip_serializing_if = "Option::is_none")] 343 pub nullified: Option<bool>, 344 #[serde(rename = "createdAt")] 345 pub created_at: String, 346} 347 348/// Format operations as an audit log 349pub fn format_audit_log(operations: &[Operation]) -> Vec<AuditLogEntry> { 350 operations 351 .iter() 352 .map(|op| AuditLogEntry { 353 did: op.did.clone(), 354 operation: op.operation.clone(), 355 cid: op.cid.clone(), 356 nullified: if op.nullified { Some(true) } else { None }, 357 created_at: op.created_at.clone(), 358 }) 359 .collect() 360} 361 362#[cfg(test)] 363mod tests { 364 use super::*; 365 use crate::operations::Operation; 366 use sonic_rs::Value; 367 368 #[test] 369 fn test_validate_did_format_valid() { 370 // Valid PLC DIDs 371 assert!(validate_did_format("did:plc:abcdefghijklmnopqrstuvwx").is_ok()); 372 assert!(validate_did_format("did:plc:234567abcdefghijklmnopqr").is_ok()); 373 assert!(validate_did_format("did:plc:zzzzzzzzzzzzzzzzzzzzzzzz").is_ok()); 374 } 375 376 #[test] 377 fn test_validate_did_format_wrong_method() { 378 let result = validate_did_format("did:web:example.com"); 379 assert!(result.is_err()); 380 assert!( 381 result 382 .unwrap_err() 383 .to_string() 384 .contains("invalid DID method") 385 ); 386 } 387 388 #[test] 389 fn test_validate_did_format_wrong_length() { 390 // Too short 391 let result = validate_did_format("did:plc:short"); 392 assert!(result.is_err()); 393 assert!( 394 result 395 .unwrap_err() 396 .to_string() 397 .contains("invalid DID length") 398 ); 399 400 // Too long 401 let result = validate_did_format("did:plc:abcdefghijklmnopqrstuvwxyz"); 402 assert!(result.is_err()); 403 } 404 405 #[test] 406 fn test_validate_did_format_invalid_chars() { 407 // Invalid characters (uppercase, numbers 0-1, 8-9, special chars) 408 assert!(validate_did_format("did:plc:ABCDEFGHIJKLMNOPQRSTUVWX").is_err()); 409 assert!(validate_did_format("did:plc:012345678901234567890123").is_err()); 410 assert!(validate_did_format("did:plc:abcdefghijklmnopqrstuvw!").is_err()); 411 } 412 413 #[test] 414 fn test_validate_did_format_base32_alphabet() { 415 // Valid base32: a-z, 2-7 416 assert!(validate_did_format("did:plc:abcdefghijklmnopqrstuvwx").is_ok()); 417 assert!(validate_did_format("did:plc:234567abcdefghijklmnopqr").is_ok()); 418 assert!(validate_did_format("did:plc:zzzzzzzzzzzzzzzzzzzzzzzz").is_ok()); 419 } 420 421 #[test] 422 fn test_format_audit_log() { 423 let operations = vec![ 424 Operation { 425 did: "did:plc:test1".to_string(), 426 operation: Value::new(), 427 cid: Some("cid1".to_string()), 428 nullified: false, 429 created_at: "2024-01-01T00:00:00Z".to_string(), 430 extra: Value::new(), 431 raw_json: None, 432 }, 433 Operation { 434 did: "did:plc:test2".to_string(), 435 operation: Value::new(), 436 cid: None, 437 nullified: true, 438 created_at: "2024-01-01T01:00:00Z".to_string(), 439 extra: Value::new(), 440 raw_json: None, 441 }, 442 ]; 443 444 let audit_log = format_audit_log(&operations); 445 assert_eq!(audit_log.len(), 2); 446 assert_eq!(audit_log[0].did, "did:plc:test1"); 447 assert_eq!(audit_log[0].cid, Some("cid1".to_string())); 448 assert_eq!(audit_log[0].nullified, None); // false is not serialized 449 assert_eq!(audit_log[1].did, "did:plc:test2"); 450 assert_eq!(audit_log[1].cid, None); 451 assert_eq!(audit_log[1].nullified, Some(true)); 452 } 453 454 #[test] 455 fn test_format_audit_log_empty() { 456 let audit_log = format_audit_log(&[]); 457 assert_eq!(audit_log.len(), 0); 458 } 459}