[DEPRECATED] Go implementation of plcbundle
1package plcclient
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{
80 DID: did,
81 RotationKeys: []string{},
82 VerificationMethods: make(map[string]string),
83 AlsoKnownAs: []string{},
84 Services: make(map[string]ServiceDefinition),
85 }
86 }
87
88 // Apply operation to state
89 applyOperationToState(state, opData)
90 }
91
92 if state == nil {
93 return nil, fmt.Errorf("no valid operations found")
94 }
95
96 return state, nil
97}
98
99// applyOperationToState updates state with data from an operation
100func applyOperationToState(state *DIDState, opData map[string]interface{}) {
101 // Update rotation keys
102 if rotKeys, ok := opData["rotationKeys"].([]interface{}); ok {
103 state.RotationKeys = make([]string, 0, len(rotKeys))
104 for _, k := range rotKeys {
105 if keyStr, ok := k.(string); ok {
106 state.RotationKeys = append(state.RotationKeys, keyStr)
107 }
108 }
109 }
110
111 // Update verification methods
112 if vm, ok := opData["verificationMethods"].(map[string]interface{}); ok {
113 state.VerificationMethods = make(map[string]string)
114 for key, val := range vm {
115 if valStr, ok := val.(string); ok {
116 state.VerificationMethods[key] = valStr
117 }
118 }
119 }
120
121 // Handle legacy signingKey format
122 if signingKey, ok := opData["signingKey"].(string); ok {
123 if state.VerificationMethods == nil {
124 state.VerificationMethods = make(map[string]string)
125 }
126 state.VerificationMethods["atproto"] = signingKey
127 }
128
129 // Update alsoKnownAs
130 if aka, ok := opData["alsoKnownAs"].([]interface{}); ok {
131 state.AlsoKnownAs = make([]string, 0, len(aka))
132 for _, a := range aka {
133 if akaStr, ok := a.(string); ok {
134 state.AlsoKnownAs = append(state.AlsoKnownAs, akaStr)
135 }
136 }
137 }
138
139 // Handle legacy handle format
140 if handle, ok := opData["handle"].(string); ok {
141 // Only set if alsoKnownAs is empty
142 if len(state.AlsoKnownAs) == 0 {
143 state.AlsoKnownAs = []string{"at://" + handle}
144 }
145 }
146
147 // Update services
148 if services, ok := opData["services"].(map[string]interface{}); ok {
149 state.Services = make(map[string]ServiceDefinition)
150 for key, svc := range services {
151 if svcMap, ok := svc.(map[string]interface{}); ok {
152 svcType, _ := svcMap["type"].(string)
153 endpoint, _ := svcMap["endpoint"].(string)
154 state.Services[key] = ServiceDefinition{
155 Type: svcType,
156 Endpoint: normalizeServiceEndpoint(endpoint),
157 }
158 }
159 }
160 }
161
162 // Handle legacy service format
163 if service, ok := opData["service"].(string); ok {
164 if state.Services == nil {
165 state.Services = make(map[string]ServiceDefinition)
166 }
167 state.Services["atproto_pds"] = ServiceDefinition{
168 Type: "AtprotoPersonalDataServer",
169 Endpoint: normalizeServiceEndpoint(service),
170 }
171 }
172}
173
174// StateToDIDDocument converts internal PLC state to W3C DID document format
175func StateToDIDDocument(state *DIDState) *DIDDocument {
176 // Base contexts - ALWAYS include multikey (matches PLC directory behavior)
177 contexts := []string{
178 "https://www.w3.org/ns/did/v1",
179 "https://w3id.org/security/multikey/v1",
180 }
181
182 hasSecp256k1 := false
183 hasP256 := false
184
185 // Check verification method key types for additional contexts
186 for _, didKey := range state.VerificationMethods {
187 keyType := detectKeyType(didKey)
188 switch keyType {
189 case "secp256k1":
190 hasSecp256k1 = true
191 case "p256":
192 hasP256 = true
193 }
194 }
195
196 // Add suite-specific contexts only if those key types are present
197 if hasSecp256k1 {
198 contexts = append(contexts, "https://w3id.org/security/suites/secp256k1-2019/v1")
199 }
200 if hasP256 {
201 contexts = append(contexts, "https://w3id.org/security/suites/ecdsa-2019/v1")
202 }
203
204 doc := &DIDDocument{
205 Context: contexts,
206 ID: state.DID,
207 AlsoKnownAs: []string{},
208 VerificationMethod: []VerificationMethod{},
209 Service: []Service{},
210 }
211
212 // Copy alsoKnownAs if present
213 if len(state.AlsoKnownAs) > 0 {
214 doc.AlsoKnownAs = state.AlsoKnownAs
215 }
216
217 // Convert services
218 for id, svc := range state.Services {
219 doc.Service = append(doc.Service, Service{
220 ID: "#" + id,
221 Type: svc.Type,
222 ServiceEndpoint: svc.Endpoint,
223 })
224 }
225
226 // Keep verification methods with full DID
227 for id, didKey := range state.VerificationMethods {
228 doc.VerificationMethod = append(doc.VerificationMethod, VerificationMethod{
229 ID: state.DID + "#" + id,
230 Type: "Multikey",
231 Controller: state.DID,
232 PublicKeyMultibase: ExtractMultibaseFromDIDKey(didKey),
233 })
234 }
235
236 return doc
237}
238
239// detectKeyType detects the key type from did:key encoding
240func detectKeyType(didKey string) string {
241 multibase := ExtractMultibaseFromDIDKey(didKey)
242
243 if len(multibase) < 3 {
244 return "unknown"
245 }
246
247 // The 'z' is the base58btc multibase prefix
248 // Actual key starts at position 1
249 switch {
250 case multibase[1] == 'Q' && multibase[2] == '3':
251 return "secp256k1" // Starts with zQ3s
252 case multibase[1] == 'D' && multibase[2] == 'n':
253 return "p256" // Starts with zDn
254 case multibase[1] == '6' && multibase[2] == 'M':
255 return "ed25519" // Starts with z6Mk
256 default:
257 return "unknown"
258 }
259}
260
261// ExtractMultibaseFromDIDKey extracts the multibase string from did:key: format
262func ExtractMultibaseFromDIDKey(didKey string) string {
263 return strings.TrimPrefix(didKey, "did:key:")
264}
265
266// ValidateDIDFormat validates did:plc format
267func ValidateDIDFormat(did string) error {
268 if !strings.HasPrefix(did, "did:plc:") {
269 return fmt.Errorf("invalid DID method: must start with 'did:plc:'")
270 }
271
272 if len(did) != 32 {
273 return fmt.Errorf("invalid DID length: expected 32 chars, got %d", len(did))
274 }
275
276 // Validate identifier part (24 chars, base32 alphabet)
277 identifier := strings.TrimPrefix(did, "did:plc:")
278 if len(identifier) != 24 {
279 return fmt.Errorf("invalid identifier length: expected 24 chars, got %d", len(identifier))
280 }
281
282 // Check base32 alphabet (a-z, 2-7, no 0189)
283 for _, c := range identifier {
284 if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) {
285 return fmt.Errorf("invalid character in identifier: %c (must be base32: a-z, 2-7)", c)
286 }
287 }
288
289 return nil
290}
291
292// FormatAuditLog formats operations as an audit log
293func FormatAuditLog(operations []PLCOperation) []AuditLogEntry {
294 log := make([]AuditLogEntry, 0, len(operations))
295
296 for _, op := range operations {
297 // Parse operation for the log
298 opData, _ := op.GetOperationData()
299
300 entry := AuditLogEntry{
301 DID: op.DID,
302 Operation: opData,
303 CID: op.CID,
304 Nullified: op.Nullified,
305 CreatedAt: op.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
306 }
307
308 log = append(log, entry)
309 }
310
311 return log
312}
313
314func normalizeServiceEndpoint(endpoint string) string {
315 // If already has protocol, return as-is
316 if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
317 return endpoint
318 }
319
320 // Legacy format: add https:// prefix
321 return "https://" + endpoint
322}