A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory
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}