A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory
at did-resolver 311 lines 8.7 kB view raw
1package plc 2 3import ( 4 "fmt" 5 "strings" 6) 7 8// DIDState represents the current state of a DID (PLC-specific format) 9type DIDState struct { 10 DID string `json:"did"` 11 RotationKeys []string `json:"rotationKeys"` 12 VerificationMethods map[string]string `json:"verificationMethods"` 13 AlsoKnownAs []string `json:"alsoKnownAs"` 14 Services map[string]ServiceDefinition `json:"services"` 15} 16 17// ServiceDefinition represents a service endpoint 18type ServiceDefinition struct { 19 Type string `json:"type"` 20 Endpoint string `json:"endpoint"` 21} 22 23// AuditLogEntry represents a single entry in the audit log 24type AuditLogEntry struct { 25 DID string `json:"did"` 26 Operation interface{} `json:"operation"` // The parsed operation data 27 CID string `json:"cid"` 28 Nullified interface{} `json:"nullified,omitempty"` 29 CreatedAt string `json:"createdAt"` 30} 31 32// ResolveDIDDocument constructs a DID document from operation log 33// This is the main entry point for DID resolution 34func ResolveDIDDocument(did string, operations []PLCOperation) (*DIDDocument, error) { 35 if len(operations) == 0 { 36 return nil, fmt.Errorf("no operations found for DID") 37 } 38 39 // Build current state from operations 40 state, err := BuildDIDState(did, operations) 41 if err != nil { 42 return nil, err 43 } 44 45 // Convert to DID document format 46 return StateToDIDDocument(state), nil 47} 48 49// BuildDIDState applies operations in order to build current DID state 50func BuildDIDState(did string, operations []PLCOperation) (*DIDState, error) { 51 var state *DIDState 52 53 for _, op := range operations { 54 // Skip nullified operations 55 if op.IsNullified() { 56 continue 57 } 58 59 // Parse operation data 60 opData, err := op.GetOperationData() 61 if err != nil { 62 return nil, fmt.Errorf("failed to parse operation: %w", err) 63 } 64 65 if opData == nil { 66 continue 67 } 68 69 // Check operation type 70 opType, _ := opData["type"].(string) 71 72 // Handle tombstone (deactivated DID) 73 if opType == "plc_tombstone" { 74 return nil, fmt.Errorf("DID has been deactivated") 75 } 76 77 // Initialize state on first operation 78 if state == nil { 79 state = &DIDState{DID: did} 80 } 81 82 // Apply operation to state 83 applyOperationToState(state, opData) 84 } 85 86 if state == nil { 87 return nil, fmt.Errorf("no valid operations found") 88 } 89 90 return state, nil 91} 92 93// applyOperationToState updates state with data from an operation 94func applyOperationToState(state *DIDState, opData map[string]interface{}) { 95 // Update rotation keys 96 if rotKeys, ok := opData["rotationKeys"].([]interface{}); ok { 97 state.RotationKeys = make([]string, 0, len(rotKeys)) 98 for _, k := range rotKeys { 99 if keyStr, ok := k.(string); ok { 100 state.RotationKeys = append(state.RotationKeys, keyStr) 101 } 102 } 103 } 104 105 // Update verification methods 106 if vm, ok := opData["verificationMethods"].(map[string]interface{}); ok { 107 state.VerificationMethods = make(map[string]string) 108 for key, val := range vm { 109 if valStr, ok := val.(string); ok { 110 state.VerificationMethods[key] = valStr 111 } 112 } 113 } 114 115 // Handle legacy signingKey format 116 if signingKey, ok := opData["signingKey"].(string); ok { 117 if state.VerificationMethods == nil { 118 state.VerificationMethods = make(map[string]string) 119 } 120 state.VerificationMethods["atproto"] = signingKey 121 } 122 123 // Update alsoKnownAs 124 if aka, ok := opData["alsoKnownAs"].([]interface{}); ok { 125 state.AlsoKnownAs = make([]string, 0, len(aka)) 126 for _, a := range aka { 127 if akaStr, ok := a.(string); ok { 128 state.AlsoKnownAs = append(state.AlsoKnownAs, akaStr) 129 } 130 } 131 } 132 133 // Handle legacy handle format 134 if handle, ok := opData["handle"].(string); ok { 135 if len(state.AlsoKnownAs) == 0 { 136 state.AlsoKnownAs = []string{"at://" + handle} 137 } 138 } 139 140 // Update services 141 if services, ok := opData["services"].(map[string]interface{}); ok { 142 state.Services = make(map[string]ServiceDefinition) 143 for key, svc := range services { 144 if svcMap, ok := svc.(map[string]interface{}); ok { 145 svcType, _ := svcMap["type"].(string) 146 endpoint, _ := svcMap["endpoint"].(string) 147 state.Services[key] = ServiceDefinition{ 148 Type: svcType, 149 Endpoint: normalizeServiceEndpoint(endpoint), 150 } 151 } 152 } 153 } 154 155 // Handle legacy service format 156 if service, ok := opData["service"].(string); ok { 157 if state.Services == nil { 158 state.Services = make(map[string]ServiceDefinition) 159 } 160 state.Services["atproto_pds"] = ServiceDefinition{ 161 Type: "AtprotoPersonalDataServer", 162 Endpoint: normalizeServiceEndpoint(service), 163 } 164 } 165} 166 167// StateToDIDDocument converts internal PLC state to W3C DID document format 168func StateToDIDDocument(state *DIDState) *DIDDocument { 169 // Detect key types to determine correct @context 170 contexts := []string{"https://www.w3.org/ns/did/v1"} 171 172 hasMultikey := false 173 hasSecp256k1 := false 174 hasP256 := false 175 176 // Check verification method key types 177 for _, didKey := range state.VerificationMethods { 178 keyType := detectKeyType(didKey) 179 switch keyType { 180 case "secp256k1": 181 hasSecp256k1 = true 182 case "p256": 183 hasP256 = true 184 default: 185 hasMultikey = true 186 } 187 } 188 189 // Add appropriate context URLs 190 if hasMultikey || hasSecp256k1 || hasP256 { 191 contexts = append(contexts, "https://w3id.org/security/multikey/v1") 192 } 193 if hasSecp256k1 { 194 contexts = append(contexts, "https://w3id.org/security/suites/secp256k1-2019/v1") 195 } 196 if hasP256 { 197 contexts = append(contexts, "https://w3id.org/security/suites/ecdsa-2019/v1") 198 } 199 200 doc := &DIDDocument{ 201 Context: contexts, 202 ID: state.DID, 203 AlsoKnownAs: state.AlsoKnownAs, 204 } 205 206 // Convert services 207 for id, svc := range state.Services { 208 doc.Service = append(doc.Service, Service{ 209 ID: "#" + id, // ← Just fragment (matching plc.directory) 210 Type: svc.Type, 211 ServiceEndpoint: svc.Endpoint, 212 }) 213 } 214 215 // Keep verification methods with full DID (they're correct): 216 for id, didKey := range state.VerificationMethods { 217 doc.VerificationMethod = append(doc.VerificationMethod, VerificationMethod{ 218 ID: state.DID + "#" + id, // ← Keep this as-is 219 Type: "Multikey", 220 Controller: state.DID, 221 PublicKeyMultibase: ExtractMultibaseFromDIDKey(didKey), 222 }) 223 } 224 225 return doc 226} 227 228// detectKeyType detects the key type from did:key encoding 229func detectKeyType(didKey string) string { 230 multibase := ExtractMultibaseFromDIDKey(didKey) 231 232 if len(multibase) < 3 { 233 return "unknown" 234 } 235 236 // The 'z' is the base58btc multibase prefix 237 // Actual key starts at position 1 238 switch { 239 case multibase[1] == 'Q' && multibase[2] == '3': // ← Fixed: was [0] and [1] 240 return "secp256k1" // Starts with zQ3s 241 case multibase[1] == 'D' && multibase[2] == 'n': // ← Fixed 242 return "p256" // Starts with zDn 243 case multibase[1] == '6' && multibase[2] == 'M': // ← Fixed 244 return "ed25519" // Starts with z6Mk 245 default: 246 return "unknown" 247 } 248} 249 250// ExtractMultibaseFromDIDKey extracts the multibase string from did:key: format 251func ExtractMultibaseFromDIDKey(didKey string) string { 252 return strings.TrimPrefix(didKey, "did:key:") 253} 254 255// ValidateDIDFormat validates did:plc format 256func ValidateDIDFormat(did string) error { 257 if !strings.HasPrefix(did, "did:plc:") { 258 return fmt.Errorf("invalid DID method: must start with 'did:plc:'") 259 } 260 261 if len(did) != 32 { 262 return fmt.Errorf("invalid DID length: expected 32 chars, got %d", len(did)) 263 } 264 265 // Validate identifier part (24 chars, base32 alphabet) 266 identifier := strings.TrimPrefix(did, "did:plc:") 267 if len(identifier) != 24 { 268 return fmt.Errorf("invalid identifier length: expected 24 chars, got %d", len(identifier)) 269 } 270 271 // Check base32 alphabet (a-z, 2-7, no 0189) 272 for _, c := range identifier { 273 if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) { 274 return fmt.Errorf("invalid character in identifier: %c (must be base32: a-z, 2-7)", c) 275 } 276 } 277 278 return nil 279} 280 281// FormatAuditLog formats operations as an audit log 282func FormatAuditLog(operations []PLCOperation) []AuditLogEntry { 283 log := make([]AuditLogEntry, 0, len(operations)) 284 285 for _, op := range operations { 286 // Parse operation for the log 287 opData, _ := op.GetOperationData() 288 289 entry := AuditLogEntry{ 290 DID: op.DID, 291 Operation: opData, 292 CID: op.CID, 293 Nullified: op.Nullified, 294 CreatedAt: op.CreatedAt.Format("2006-01-02T15:04:05.000Z"), 295 } 296 297 log = append(log, entry) 298 } 299 300 return log 301} 302 303func normalizeServiceEndpoint(endpoint string) string { 304 // If already has protocol, return as-is 305 if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { 306 return endpoint 307 } 308 309 // Legacy format: add https:// prefix 310 return "https://" + endpoint 311}