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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +190 -0
- data/README.md +222 -0
- data/lib/did_resolver/cache.rb +73 -0
- data/lib/did_resolver/did_document.rb +186 -0
- data/lib/did_resolver/methods/jwk.rb +175 -0
- data/lib/did_resolver/methods/key.rb +386 -0
- data/lib/did_resolver/methods/web.rb +154 -0
- data/lib/did_resolver/methods.rb +12 -0
- data/lib/did_resolver/resolver.rb +134 -0
- data/lib/did_resolver/version.rb +5 -0
- data/lib/did_resolver.rb +201 -0
- metadata +176 -0
|
@@ -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
|