High-performance implementation of plcbundle written in Rust
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}