did_resolver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "json"
6
+
7
+ module DidResolver
8
+ module Methods
9
+ # DID Key Method Resolver
10
+ #
11
+ # Resolves did:key DIDs according to the DID Key Method Specification
12
+ # @see https://w3c-ccg.github.io/did-method-key/
13
+ #
14
+ # did:key is a self-describing DID that encodes the public key directly
15
+ # in the DID identifier using multibase + multicodec encoding.
16
+ #
17
+ # Supported key types:
18
+ # - Ed25519 (multicodec: 0xed)
19
+ # - X25519 (multicodec: 0xec)
20
+ # - secp256k1 (multicodec: 0xe7)
21
+ # - P-256 (multicodec: 0x1200)
22
+ # - P-384 (multicodec: 0x1201)
23
+ # - P-521 (multicodec: 0x1202)
24
+ # - RSA (multicodec: 0x1205)
25
+ # - jwk_jcs-pub (multicodec: 0xeb51) - EBSI/JCS encoded JWK
26
+ #
27
+ # @example
28
+ # resolver = DidResolver::Resolver.new(DidResolver::Methods::Key.resolver)
29
+ # result = resolver.resolve("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
30
+ #
31
+ class Key
32
+ # Multicodec prefixes for key types
33
+ # @see https://github.com/multiformats/multicodec/blob/master/table.csv
34
+ MULTICODEC = {
35
+ ed25519_pub: 0xed,
36
+ x25519_pub: 0xec,
37
+ secp256k1_pub: 0xe7,
38
+ p256_pub: 0x1200,
39
+ p384_pub: 0x1201,
40
+ p521_pub: 0x1202,
41
+ rsa_pub: 0x1205,
42
+ jwk_jcs_pub: 0xeb51 # EBSI jwk_jcs-pub codec
43
+ }.freeze
44
+
45
+ # Reverse lookup
46
+ MULTICODEC_TO_TYPE = MULTICODEC.invert.freeze
47
+
48
+ # Key type to verification method type mapping
49
+ KEY_TYPE_TO_VM_TYPE = {
50
+ ed25519_pub: "Ed25519VerificationKey2020",
51
+ x25519_pub: "X25519KeyAgreementKey2020",
52
+ secp256k1_pub: "EcdsaSecp256k1VerificationKey2019",
53
+ p256_pub: "JsonWebKey2020",
54
+ p384_pub: "JsonWebKey2020",
55
+ p521_pub: "JsonWebKey2020",
56
+ rsa_pub: "JsonWebKey2020",
57
+ jwk_jcs_pub: "JsonWebKey2020"
58
+ }.freeze
59
+
60
+ # Key type to JWK curve mapping
61
+ KEY_TYPE_TO_CURVE = {
62
+ ed25519_pub: "Ed25519",
63
+ x25519_pub: "X25519",
64
+ secp256k1_pub: "secp256k1",
65
+ p256_pub: "P-256",
66
+ p384_pub: "P-384",
67
+ p521_pub: "P-521"
68
+ # jwk_jcs_pub curve comes from the embedded JWK
69
+ }.freeze
70
+
71
+ class << self
72
+ # Get the resolver hash for registration
73
+ # @return [Hash] { "key" => resolve_proc }
74
+ def resolver
75
+ { "key" => method(:resolve) }
76
+ end
77
+
78
+ # Resolve a did:key DID
79
+ # @param did [String] The full DID string
80
+ # @param parsed [ParsedDID] Parsed DID components
81
+ # @param _resolver [Resolver] Parent resolver
82
+ # @param _options [Hash] Resolution options
83
+ # @return [ResolutionResult]
84
+ def resolve(did, parsed, _resolver, _options = {})
85
+ # Extract the method-specific identifier (without fragment)
86
+ method_specific_id = parsed.id
87
+
88
+ # Parse the multibase-encoded key
89
+ key_data = decode_multibase_key(method_specific_id)
90
+ return key_data if key_data.is_a?(ResolutionResult) # Error case
91
+
92
+ key_type = key_data[:type]
93
+ public_key_bytes = key_data[:bytes]
94
+
95
+ # Build the DID Document based on key type
96
+ did_document = if key_type == :jwk_jcs_pub
97
+ build_did_document_from_jwk_jcs(did, method_specific_id, public_key_bytes)
98
+ else
99
+ build_did_document(did, key_type, public_key_bytes)
100
+ end
101
+
102
+ return did_document if did_document.is_a?(ResolutionResult) # Error case
103
+
104
+ ResolutionResult.success(did_document)
105
+ rescue StandardError => e
106
+ ResolutionResult.error("invalidDid", "Failed to resolve did:key: #{e.message}")
107
+ end
108
+
109
+ private
110
+
111
+ # Decode a multibase-encoded public key
112
+ # @param multibase_key [String] The multibase-encoded key (e.g., z6Mk...)
113
+ # @return [Hash] { type: Symbol, bytes: String }
114
+ def decode_multibase_key(multibase_key)
115
+ # Check multibase prefix (z = base58btc)
116
+ unless multibase_key.start_with?("z")
117
+ return ResolutionResult.invalid_did(
118
+ "did:key:#{multibase_key}",
119
+ "Unsupported multibase encoding. Expected 'z' (base58btc)"
120
+ )
121
+ end
122
+
123
+ # Decode base58btc (without the 'z' prefix)
124
+ encoded = multibase_key[1..]
125
+ decoded = decode_base58(encoded)
126
+
127
+ # Extract multicodec prefix
128
+ multicodec, key_bytes = extract_multicodec(decoded)
129
+
130
+ key_type = MULTICODEC_TO_TYPE[multicodec]
131
+ unless key_type
132
+ return ResolutionResult.invalid_did(
133
+ "did:key:#{multibase_key}",
134
+ "Unsupported key type multicodec: 0x#{multicodec.to_s(16)}"
135
+ )
136
+ end
137
+
138
+ { type: key_type, bytes: key_bytes }
139
+ end
140
+
141
+ # Extract multicodec prefix from bytes
142
+ # Multicodec uses unsigned varint encoding (LEB128)
143
+ # @see https://github.com/multiformats/unsigned-varint
144
+ def extract_multicodec(bytes)
145
+ decode_uvarint(bytes)
146
+ end
147
+
148
+ # Decode an unsigned varint (LEB128) from the beginning of bytes
149
+ # @param bytes [String] Binary string
150
+ # @return [Array<Integer, String>] [value, remaining_bytes]
151
+ def decode_uvarint(bytes)
152
+ result = 0
153
+ shift = 0
154
+ offset = 0
155
+
156
+ loop do
157
+ raise "Varint too long" if offset >= 9 # Max 9 bytes for 64-bit
158
+ raise "Unexpected end of bytes" if offset >= bytes.bytesize
159
+
160
+ byte = bytes[offset].ord
161
+ offset += 1
162
+
163
+ # Add the lower 7 bits to result
164
+ result |= (byte & 0x7f) << shift
165
+ shift += 7
166
+
167
+ # If MSB is 0, we're done
168
+ break if (byte & 0x80).zero?
169
+ end
170
+
171
+ [result, bytes[offset..]]
172
+ end
173
+
174
+ # Build DID Document for the key
175
+ def build_did_document(did, key_type, public_key_bytes)
176
+ vm_type = KEY_TYPE_TO_VM_TYPE[key_type]
177
+ vm_id = "#{did}##{did.split(':').last}"
178
+
179
+ # Build verification method
180
+ verification_method = {
181
+ "id" => vm_id,
182
+ "type" => vm_type,
183
+ "controller" => did
184
+ }
185
+
186
+ # Add public key in appropriate format
187
+ case key_type
188
+ when :ed25519_pub, :x25519_pub
189
+ # Use multibase encoding for Ed25519/X25519
190
+ verification_method["publicKeyMultibase"] = "z" + encode_base58(
191
+ [MULTICODEC[key_type]].pack("C") + public_key_bytes
192
+ )
193
+ else
194
+ # Use JWK for other key types
195
+ verification_method["publicKeyJwk"] = build_jwk(key_type, public_key_bytes)
196
+ end
197
+
198
+ # Build verification relationships based on key type
199
+ authentication = []
200
+ assertion_method = []
201
+ key_agreement = []
202
+ capability_invocation = []
203
+ capability_delegation = []
204
+
205
+ case key_type
206
+ when :ed25519_pub
207
+ authentication << vm_id
208
+ assertion_method << vm_id
209
+ capability_invocation << vm_id
210
+ capability_delegation << vm_id
211
+ when :x25519_pub
212
+ key_agreement << vm_id
213
+ when :secp256k1_pub, :p256_pub, :p384_pub, :p521_pub
214
+ authentication << vm_id
215
+ assertion_method << vm_id
216
+ capability_invocation << vm_id
217
+ capability_delegation << vm_id
218
+ end
219
+
220
+ DIDDocument.new(
221
+ id: did,
222
+ context: [
223
+ "https://www.w3.org/ns/did/v1",
224
+ "https://w3id.org/security/suites/ed25519-2020/v1",
225
+ "https://w3id.org/security/suites/x25519-2020/v1"
226
+ ],
227
+ verification_method: [verification_method],
228
+ authentication: authentication.any? ? authentication : nil,
229
+ assertion_method: assertion_method.any? ? assertion_method : nil,
230
+ key_agreement: key_agreement.any? ? key_agreement : nil,
231
+ capability_invocation: capability_invocation.any? ? capability_invocation : nil,
232
+ capability_delegation: capability_delegation.any? ? capability_delegation : nil
233
+ )
234
+ end
235
+
236
+ # Build DID Document from jwk_jcs-pub encoded JWK
237
+ # This is the EBSI format where the key bytes are a JSON-encoded JWK
238
+ # @see https://github.com/multiformats/multicodec/pull/307
239
+ def build_did_document_from_jwk_jcs(did, method_specific_id, public_key_bytes)
240
+ # The bytes are UTF-8 encoded JSON of the JWK
241
+ jwk_json = public_key_bytes.force_encoding("UTF-8")
242
+
243
+ begin
244
+ public_key_jwk = JSON.parse(jwk_json)
245
+ rescue JSON::ParserError => e
246
+ return ResolutionResult.error("invalidDid", "Invalid JWK JSON in did:key: #{e.message}")
247
+ end
248
+
249
+ # Validate JWK has required fields
250
+ unless public_key_jwk["kty"]
251
+ return ResolutionResult.error("invalidDid", "JWK missing required 'kty' parameter")
252
+ end
253
+
254
+ # Verify the JWK is in canonical form (JCS - lexicographically sorted)
255
+ canonical_jwk = canonicalize_jwk(public_key_jwk)
256
+ if JSON.generate(public_key_jwk) != JSON.generate(canonical_jwk)
257
+ return ResolutionResult.error("invalidDid", "The JWK embedded in the DID is not correctly formatted (must be JCS canonical)")
258
+ end
259
+
260
+ # Build key ID using the method-specific identifier
261
+ key_id = "#{did}##{method_specific_id}"
262
+
263
+ verification_method = {
264
+ "id" => key_id,
265
+ "type" => "JsonWebKey2020",
266
+ "controller" => did,
267
+ "publicKeyJwk" => public_key_jwk
268
+ }
269
+
270
+ # Build verification relationships
271
+ # All signing-capable keys get all relationships
272
+ DIDDocument.new(
273
+ id: did,
274
+ context: [
275
+ "https://www.w3.org/ns/did/v1",
276
+ "https://w3id.org/security/suites/jws-2020/v1"
277
+ ],
278
+ verification_method: [verification_method],
279
+ authentication: [key_id],
280
+ assertion_method: [key_id],
281
+ capability_invocation: [key_id],
282
+ capability_delegation: [key_id]
283
+ )
284
+ end
285
+
286
+ # Canonicalize JWK according to JCS (only required members, lexicographically sorted)
287
+ # @see https://www.rfc-editor.org/rfc/rfc7638 (JWK Thumbprint)
288
+ def canonicalize_jwk(jwk)
289
+ case jwk["kty"]
290
+ when "EC"
291
+ { "crv" => jwk["crv"], "kty" => jwk["kty"], "x" => jwk["x"], "y" => jwk["y"] }
292
+ when "OKP"
293
+ { "crv" => jwk["crv"], "kty" => jwk["kty"], "x" => jwk["x"] }
294
+ when "RSA"
295
+ { "e" => jwk["e"], "kty" => jwk["kty"], "n" => jwk["n"] }
296
+ else
297
+ jwk
298
+ end
299
+ end
300
+
301
+ # Build JWK from public key bytes
302
+ def build_jwk(key_type, public_key_bytes)
303
+ curve = KEY_TYPE_TO_CURVE[key_type]
304
+
305
+ case key_type
306
+ when :secp256k1_pub, :p256_pub, :p384_pub, :p521_pub
307
+ # EC key - uncompressed point format (04 || x || y)
308
+ if public_key_bytes[0] == "\x04"
309
+ # Uncompressed
310
+ coord_length = (public_key_bytes.bytesize - 1) / 2
311
+ x = public_key_bytes[1, coord_length]
312
+ y = public_key_bytes[1 + coord_length, coord_length]
313
+ else
314
+ # Compressed - would need to decompress
315
+ # For now, just use the raw bytes
316
+ x = public_key_bytes
317
+ y = nil
318
+ end
319
+
320
+ jwk = {
321
+ "kty" => "EC",
322
+ "crv" => curve,
323
+ "x" => base64url_encode(x)
324
+ }
325
+ jwk["y"] = base64url_encode(y) if y
326
+ jwk
327
+ when :rsa_pub
328
+ # RSA public key (DER encoded)
329
+ {
330
+ "kty" => "RSA",
331
+ "n" => base64url_encode(public_key_bytes),
332
+ "e" => base64url_encode("\x01\x00\x01") # Common exponent 65537
333
+ }
334
+ else
335
+ # OKP keys (Ed25519, X25519)
336
+ {
337
+ "kty" => "OKP",
338
+ "crv" => curve,
339
+ "x" => base64url_encode(public_key_bytes)
340
+ }
341
+ end
342
+ end
343
+
344
+ # Base58 Bitcoin alphabet
345
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
346
+
347
+ def decode_base58(str)
348
+ int_val = 0
349
+ str.each_char do |c|
350
+ int_val = int_val * 58 + BASE58_ALPHABET.index(c)
351
+ end
352
+
353
+ # Convert to bytes
354
+ hex = int_val.to_s(16)
355
+ hex = "0" + hex if hex.length.odd?
356
+
357
+ # Handle leading zeros
358
+ leading_zeros = str.chars.take_while { |c| c == "1" }.count
359
+ ("\x00" * leading_zeros) + [hex].pack("H*")
360
+ end
361
+
362
+ def encode_base58(bytes)
363
+ # Count leading zeros
364
+ leading_zeros = bytes.bytes.take_while(&:zero?).count
365
+
366
+ # Convert to integer
367
+ int_val = bytes.unpack1("H*").to_i(16)
368
+
369
+ # Convert to base58
370
+ result = ""
371
+ while int_val > 0
372
+ int_val, remainder = int_val.divmod(58)
373
+ result = BASE58_ALPHABET[remainder] + result
374
+ end
375
+
376
+ # Add leading 1s for each leading zero byte
377
+ ("1" * leading_zeros) + result
378
+ end
379
+
380
+ def base64url_encode(bytes)
381
+ Base64.urlsafe_encode64(bytes, padding: false)
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module DidResolver
8
+ module Methods
9
+ # DID Web Method Resolver
10
+ #
11
+ # Resolves did:web DIDs according to the DID Web Method Specification
12
+ # @see https://w3c-ccg.github.io/did-method-web/
13
+ #
14
+ # DID Web syntax:
15
+ # did:web:<domain> -> https://<domain>/.well-known/did.json
16
+ # did:web:<domain>:<path> -> https://<domain>/<path>/did.json
17
+ # did:web:<domain>%3A<port> -> https://<domain>:<port>/.well-known/did.json
18
+ #
19
+ # @example
20
+ # resolver = DidResolver::Resolver.new(DidResolver::Methods::Web.resolver)
21
+ # result = resolver.resolve("did:web:example.com")
22
+ #
23
+ class Web
24
+ DEFAULT_TIMEOUT = 10
25
+
26
+ class << self
27
+ # Get the resolver hash for registration
28
+ # @return [Hash] { "web" => resolve_proc }
29
+ def resolver
30
+ { "web" => method(:resolve) }
31
+ end
32
+
33
+ # Resolve a did:web DID
34
+ # @param did [String] The full DID string
35
+ # @param parsed [ParsedDID] Parsed DID components
36
+ # @param _resolver [Resolver] Parent resolver (for recursive resolution)
37
+ # @param options [Hash] Resolution options
38
+ # @return [ResolutionResult]
39
+ def resolve(did, parsed, _resolver, options = {})
40
+ url = build_url(parsed.id)
41
+
42
+ response = fetch_did_document(url, options)
43
+
44
+ case response
45
+ when Net::HTTPSuccess
46
+ parse_response(did, response.body)
47
+ when Net::HTTPNotFound
48
+ ResolutionResult.not_found(did)
49
+ else
50
+ ResolutionResult.error("networkError", "HTTP #{response.code}: #{response.message}")
51
+ end
52
+ rescue URI::InvalidURIError => e
53
+ ResolutionResult.invalid_did(did, "Invalid domain in DID: #{e.message}")
54
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
55
+ ResolutionResult.error("networkError", "Connection failed: #{e.message}")
56
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
57
+ ResolutionResult.error("networkError", "Request timeout: #{e.message}")
58
+ rescue JSON::ParserError => e
59
+ ResolutionResult.error("invalidDidDocument", "Invalid JSON in DID Document: #{e.message}")
60
+ rescue StandardError => e
61
+ ResolutionResult.error("internalError", e.message)
62
+ end
63
+
64
+ private
65
+
66
+ # Build the HTTPS URL for the DID document
67
+ # @param method_specific_id [String] The method-specific identifier
68
+ # @return [String] The URL
69
+ #
70
+ # According to did:web spec:
71
+ # - Colons in the method-specific-id separate path segments
72
+ # - Percent-encoded colons (%3A) represent literal colons (for port numbers)
73
+ # - Example: did:web:example.com -> https://example.com/.well-known/did.json
74
+ # - Example: did:web:example.com:users -> https://example.com/users/did.json
75
+ # - Example: did:web:localhost%3A8080 -> https://localhost:8080/.well-known/did.json
76
+ #
77
+ def build_url(method_specific_id)
78
+ # First, split on unencoded colons to get path parts
79
+ parts = method_specific_id.split(":")
80
+
81
+ # Decode percent-encoded colons in each part (these are literal colons, e.g., port)
82
+ decoded_parts = parts.map { |p| p.gsub("%3A", ":").gsub("%3a", ":") }
83
+
84
+ # First part is the domain (possibly with port from decoded %3A)
85
+ domain = decoded_parts.first
86
+
87
+ # Remaining parts form the path
88
+ path_parts = decoded_parts[1..]
89
+
90
+ if path_parts.empty?
91
+ # No path -> use .well-known
92
+ "https://#{domain}/.well-known/did.json"
93
+ else
94
+ # Path specified -> use path/did.json
95
+ path = path_parts.join("/")
96
+ "https://#{domain}/#{path}/did.json"
97
+ end
98
+ end
99
+
100
+ # Fetch the DID document from the URL
101
+ def fetch_did_document(url, options = {})
102
+ uri = URI.parse(url)
103
+ timeout = options[:timeout] || DEFAULT_TIMEOUT
104
+
105
+ http = Net::HTTP.new(uri.host, uri.port)
106
+ http.use_ssl = (uri.scheme == "https")
107
+ http.open_timeout = timeout
108
+ http.read_timeout = timeout
109
+
110
+ # Some servers require a proper User-Agent
111
+ headers = {
112
+ "Accept" => "application/did+ld+json, application/json",
113
+ "User-Agent" => "DidResolver/#{VERSION} Ruby/#{RUBY_VERSION}"
114
+ }
115
+
116
+ request = Net::HTTP::Get.new(uri.request_uri, headers)
117
+ http.request(request)
118
+ end
119
+
120
+ # Parse the response body as a DID Document
121
+ def parse_response(did, body)
122
+ data = JSON.parse(body)
123
+
124
+ # Validate the document ID matches the DID
125
+ doc_id = data["id"]
126
+ unless doc_id == did
127
+ return ResolutionResult.error(
128
+ "invalidDidDocument",
129
+ "DID Document id '#{doc_id}' does not match DID '#{did}'"
130
+ )
131
+ end
132
+
133
+ did_document = DIDDocument.from_hash(data)
134
+
135
+ ResolutionResult.success(
136
+ did_document,
137
+ document_metadata: extract_metadata(data)
138
+ )
139
+ end
140
+
141
+ def extract_metadata(data)
142
+ metadata = {}
143
+
144
+ # Extract common metadata fields if present
145
+ metadata[:created] = data["created"] if data["created"]
146
+ metadata[:updated] = data["updated"] if data["updated"]
147
+ metadata[:deactivated] = data["deactivated"] if data.key?("deactivated")
148
+
149
+ metadata
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load method resolvers
4
+ require_relative "methods/web"
5
+ require_relative "methods/key"
6
+ require_relative "methods/jwk"
7
+
8
+ module DidResolver
9
+ # Methods namespace for DID method resolvers
10
+ module Methods
11
+ end
12
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DidResolver
4
+ # Universal DID Resolver
5
+ #
6
+ # Resolves DIDs by delegating to registered method resolvers.
7
+ # Inspired by https://github.com/decentralized-identity/did-resolver
8
+ #
9
+ # @example
10
+ # resolver = Resolver.new(
11
+ # DidResolver::Methods::Web.resolver,
12
+ # DidResolver::Methods::Key.resolver
13
+ # )
14
+ # result = resolver.resolve("did:web:example.com")
15
+ #
16
+ class Resolver
17
+ attr_reader :registry
18
+
19
+ # @param method_resolvers [Array<Hash>] Method resolver hashes { method_name => resolve_proc }
20
+ # @param cache [Cache, Boolean, nil] Cache implementation or true for default cache
21
+ # @param logger [Logger, nil] Optional logger for error messages
22
+ def initialize(*method_resolvers, cache: nil, logger: nil)
23
+ @registry = {}
24
+ @cache = build_cache(cache)
25
+ @logger = logger
26
+
27
+ # Register all provided method resolvers
28
+ method_resolvers.flatten.each do |resolver_hash|
29
+ register(resolver_hash)
30
+ end
31
+ end
32
+
33
+ # Register a method resolver
34
+ # @param resolver_hash [Hash] { method_name => resolve_proc }
35
+ def register(resolver_hash)
36
+ resolver_hash.each do |method_name, resolve_proc|
37
+ @registry[method_name.to_s] = resolve_proc
38
+ end
39
+ end
40
+
41
+ # Resolve a DID to its DID Document
42
+ # @param did [String] The DID or DID URL to resolve
43
+ # @param options [Hash] Resolution options
44
+ # @option options [Boolean] :no_cache Skip cache lookup
45
+ # @return [ResolutionResult]
46
+ def resolve(did, **options)
47
+ # Parse the DID
48
+ parsed = ParsedDID.parse(did)
49
+
50
+ # Check cache first (unless no_cache is set)
51
+ if @cache && !options[:no_cache] && !parsed.params["no-cache"]
52
+ cached = @cache.get(parsed.did)
53
+ return cached if cached
54
+ end
55
+
56
+ # Find method resolver
57
+ method_resolver = @registry[parsed.method]
58
+ unless method_resolver
59
+ return ResolutionResult.method_not_supported(parsed.method)
60
+ end
61
+
62
+ # Resolve
63
+ result = method_resolver.call(parsed.did, parsed, self, options)
64
+
65
+ # Cache successful results
66
+ if @cache && !result.error? && result.did_document
67
+ @cache.set(parsed.did, result)
68
+ end
69
+
70
+ result
71
+ rescue InvalidDIDError => e
72
+ ResolutionResult.invalid_did(did, e.message)
73
+ rescue NotFoundError => e
74
+ ResolutionResult.not_found(did)
75
+ rescue NetworkError => e
76
+ ResolutionResult.error("networkError", e.message)
77
+ rescue StandardError => e
78
+ @logger&.error("[DID Resolver] Unexpected error: #{e.message}")
79
+ ResolutionResult.error("internalError", e.message)
80
+ end
81
+
82
+ # Check if a method is supported
83
+ # @param method [String] DID method name
84
+ # @return [Boolean]
85
+ def supports?(method)
86
+ @registry.key?(method.to_s)
87
+ end
88
+
89
+ # List supported methods
90
+ # @return [Array<String>]
91
+ def supported_methods
92
+ @registry.keys
93
+ end
94
+
95
+ private
96
+
97
+ def build_cache(cache_option)
98
+ case cache_option
99
+ when true
100
+ Cache.new
101
+ when Cache
102
+ cache_option
103
+ when nil, false
104
+ nil
105
+ else
106
+ # Assume it's a custom cache implementation with get/set methods
107
+ cache_option
108
+ end
109
+ end
110
+
111
+ class << self
112
+ # Default resolver with common methods registered
113
+ # @return [Resolver]
114
+ def default
115
+ @default ||= new(
116
+ Methods::Web.resolver,
117
+ Methods::Key.resolver,
118
+ Methods::Jwk.resolver,
119
+ cache: true
120
+ )
121
+ end
122
+
123
+ # Reset the default resolver (useful for testing)
124
+ def reset_default!
125
+ @default = nil
126
+ end
127
+
128
+ # Shortcut to resolve using default resolver
129
+ def resolve(did, **options)
130
+ default.resolve(did, **options)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DidResolver
4
+ VERSION = "0.1.0"
5
+ end