didkit 0.3.3 → 0.5.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 +4 -4
- data/CHANGELOG.md +46 -2
- data/README.md +35 -5
- data/SECURITY.md +16 -0
- data/lib/didkit/at_handles.rb +34 -2
- data/lib/didkit/constants.rb +31 -0
- data/lib/didkit/did.rb +86 -30
- data/lib/didkit/document.rb +70 -22
- data/lib/didkit/errors.rb +6 -0
- data/lib/didkit/global_did.rb +5 -0
- data/lib/didkit/plc_importer.rb +18 -32
- data/lib/didkit/plc_operation.rb +85 -20
- data/lib/didkit/public_key.rb +116 -0
- data/lib/didkit/requests.rb +8 -11
- data/lib/didkit/resolver.rb +111 -43
- data/lib/didkit/services.rb +34 -4
- data/lib/didkit/version.rb +1 -1
- data/lib/didkit.rb +2 -2
- metadata +22 -4
- data/sig/didkit.rbs +0 -4
data/lib/didkit/plc_importer.rb
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'set'
|
|
4
5
|
require 'time'
|
|
5
6
|
require 'uri'
|
|
6
7
|
|
|
8
|
+
require_relative 'constants'
|
|
7
9
|
require_relative 'errors'
|
|
8
10
|
require_relative 'plc_operation'
|
|
9
11
|
require_relative 'requests'
|
|
10
12
|
|
|
11
13
|
#
|
|
12
|
-
# NOTE: this class is pending a rewrite
|
|
13
|
-
# Things will change here in v. 0.
|
|
14
|
+
# NOTE: this class is pending a rewrite to handle updated plc.directory APIs.
|
|
15
|
+
# Things will change here in v. 0.6.
|
|
14
16
|
#
|
|
15
17
|
|
|
16
18
|
module DIDKit
|
|
17
19
|
class PLCImporter
|
|
18
|
-
PLC_SERVICE = 'plc.directory'
|
|
19
20
|
MAX_PAGE = 1000
|
|
20
21
|
|
|
21
22
|
include Requests
|
|
22
23
|
|
|
23
|
-
attr_accessor :
|
|
24
|
+
attr_accessor :last_date
|
|
24
25
|
|
|
25
|
-
def initialize(since: nil)
|
|
26
|
+
def initialize(since: nil, service: nil)
|
|
26
27
|
if since.to_s == 'beginning'
|
|
27
28
|
@last_date = nil
|
|
28
29
|
elsif since.is_a?(String)
|
|
@@ -35,24 +36,13 @@ module DIDKit
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
@last_page_cids = []
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def plc_service
|
|
41
|
-
PLC_SERVICE
|
|
42
|
-
end
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
@
|
|
46
|
-
|
|
47
|
-
if val
|
|
48
|
-
@error_handler = proc { |e, j| "(ignore error)" }
|
|
49
|
-
else
|
|
50
|
-
@error_handler = nil
|
|
51
|
-
end
|
|
40
|
+
@plc_service = service || DIDKit::DEFAULT_PLC_DIRECTORY
|
|
41
|
+
@plc_service = "https://#{@plc_service}" unless @plc_service.include?('://')
|
|
52
42
|
end
|
|
53
43
|
|
|
54
44
|
def get_export(args = {})
|
|
55
|
-
url = URI("
|
|
45
|
+
url = URI("#{@plc_service}/export")
|
|
56
46
|
url.query = URI.encode_www_form(args)
|
|
57
47
|
|
|
58
48
|
data = get_data(url, content_type: 'application/jsonlines')
|
|
@@ -60,23 +50,16 @@ module DIDKit
|
|
|
60
50
|
end
|
|
61
51
|
|
|
62
52
|
def fetch_audit_log(did)
|
|
63
|
-
json = get_json("
|
|
53
|
+
json = get_json("#{@plc_service}/#{did}/log/audit", :content_type => :json)
|
|
64
54
|
json.map { |j| PLCOperation.new(j) }
|
|
65
|
-
end
|
|
55
|
+
end
|
|
66
56
|
|
|
67
57
|
def fetch_page
|
|
68
|
-
request_time = Time.now
|
|
69
|
-
|
|
70
58
|
query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {}
|
|
71
59
|
rows = get_export(query)
|
|
72
60
|
|
|
73
|
-
operations = rows.
|
|
74
|
-
|
|
75
|
-
PLCOperation.new(json)
|
|
76
|
-
rescue FormatError => e
|
|
77
|
-
@error_handler ? @error_handler.call(e, json) : raise
|
|
78
|
-
nil
|
|
79
|
-
end
|
|
61
|
+
operations = rows.map { |json|
|
|
62
|
+
PLCOperation.new(json)
|
|
80
63
|
}.reject { |op|
|
|
81
64
|
# when you pass the most recent op's timestamp to ?after, it will be returned as the first op again,
|
|
82
65
|
# so we need to use this CID list to filter it out (so pages will usually be 999 items long)
|
|
@@ -84,8 +67,11 @@ module DIDKit
|
|
|
84
67
|
@last_page_cids.include?(op.cid)
|
|
85
68
|
}
|
|
86
69
|
|
|
87
|
-
|
|
88
|
-
|
|
70
|
+
unless operations.empty?
|
|
71
|
+
@last_date = operations.last.created_at
|
|
72
|
+
@last_page_cids = Set.new(operations.map(&:cid))
|
|
73
|
+
end
|
|
74
|
+
|
|
89
75
|
@eof = (rows.length < MAX_PAGE)
|
|
90
76
|
|
|
91
77
|
operations
|
data/lib/didkit/plc_operation.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'time'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'at_handles'
|
|
6
6
|
require_relative 'errors'
|
|
7
|
+
require_relative 'public_key'
|
|
7
8
|
require_relative 'service_record'
|
|
8
9
|
require_relative 'services'
|
|
9
10
|
|
|
@@ -35,19 +36,10 @@ module DIDKit
|
|
|
35
36
|
attr_reader :created_at
|
|
36
37
|
|
|
37
38
|
# Returns the `type` field of the operation (usually `"plc_operation"`).
|
|
38
|
-
# @return [
|
|
39
|
+
# @return [Symbol] the operation type
|
|
39
40
|
attr_reader :type
|
|
40
41
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# Note: the handles aren't guaranteed to be verified (validated in the other direction).
|
|
44
|
-
# Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is
|
|
45
|
-
# correctly verified.
|
|
46
|
-
#
|
|
47
|
-
# @return [Array<String>]
|
|
48
|
-
attr_reader :handles
|
|
49
|
-
|
|
50
|
-
# @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
|
|
42
|
+
# @return [Array<ServiceRecord>, nil] service records like PDS details assigned to the DID
|
|
51
43
|
attr_reader :services
|
|
52
44
|
|
|
53
45
|
|
|
@@ -84,24 +76,97 @@ module DIDKit
|
|
|
84
76
|
raise FormatError, "Invalid operation type: #{type.inspect}" unless type.is_a?(String)
|
|
85
77
|
|
|
86
78
|
@type = type.to_sym
|
|
87
|
-
return unless @type == :plc_operation
|
|
88
79
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
case @type
|
|
81
|
+
when :plc_operation
|
|
82
|
+
raise FormatError, "Missing services key: #{json}" if operation['services'].nil?
|
|
83
|
+
|
|
84
|
+
parse_services(operation['services'])
|
|
85
|
+
parse_also_known_as(operation['alsoKnownAs'])
|
|
86
|
+
when :create
|
|
87
|
+
parse_legacy_ops(operation)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean] if the operation has been nullified through a rotation operation
|
|
92
|
+
def nullified?
|
|
93
|
+
@json['nullified'] == true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the account's public signing key listed in this PLC operation under `verificationMethods`.
|
|
97
|
+
#
|
|
98
|
+
# @return [PublicKey, nil] decoded signing key wrapped in {PublicKey}, or nil for `tombstone` operations
|
|
99
|
+
# @raise [FormatError] when a required field is missing or has a wrong type
|
|
100
|
+
# @raise [PublicKeyError] when the key is invalid, incorrectly encoded, or has unsupported type
|
|
101
|
+
|
|
102
|
+
def signing_key
|
|
103
|
+
case @type
|
|
104
|
+
when :plc_operation
|
|
105
|
+
methods = @json['operation']['verificationMethods']
|
|
106
|
+
raise FormatError, "Missing verificationMethods key: #{@json}" if methods.nil?
|
|
107
|
+
raise FormatError, "Invalid verificationMethods key: #{methods.inspect}" unless methods.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
key = methods['atproto']
|
|
110
|
+
raise FormatError, "Missing verificationMethods.atproto key: #{@json}" if key.nil?
|
|
111
|
+
raise FormatError, "Invalid verificationMethods.atproto key: #{key.inspect}" unless key.is_a?(String)
|
|
112
|
+
raise PublicKeyError, "Key should start with did:key: #{key.inspect}" unless key.start_with?('did:key:')
|
|
113
|
+
|
|
114
|
+
PublicKey.new(key[8..-1])
|
|
115
|
+
when :create
|
|
116
|
+
key = @json['operation']['signingKey']
|
|
117
|
+
raise FormatError, "Missing signingKey field: #{@json}" if key.nil?
|
|
118
|
+
raise FormatError, "Invalid signingKey field: #{key.inspect}" unless key.is_a?(String)
|
|
119
|
+
raise PublicKeyError, "Key should start with did:key: #{key.inspect}" unless key.start_with?('did:key:')
|
|
120
|
+
|
|
121
|
+
PublicKey.new(key[8..-1])
|
|
122
|
+
else
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
92
126
|
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def parse_services(service_data)
|
|
131
|
+
raise FormatError, "Invalid services data: #{service_data.inspect}" unless service_data.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
@services = []
|
|
134
|
+
|
|
135
|
+
service_data.each do |key, data|
|
|
136
|
+
type, endpoint = data.values_at('type', 'endpoint')
|
|
95
137
|
|
|
96
138
|
raise FormatError, "Missing service type" unless type
|
|
97
139
|
raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
|
|
140
|
+
|
|
98
141
|
raise FormatError, "Missing service endpoint" unless endpoint
|
|
99
142
|
raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
|
|
100
143
|
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
begin
|
|
145
|
+
@services << ServiceRecord.new(key, type, endpoint)
|
|
146
|
+
rescue FormatError => e
|
|
147
|
+
# ignore services with invalid URIs
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_legacy_ops(operation)
|
|
153
|
+
@handles = []
|
|
154
|
+
@services = []
|
|
155
|
+
|
|
156
|
+
if handle = operation['handle']
|
|
157
|
+
raise FormatError, "Handle should be a string" unless handle.is_a?(String)
|
|
158
|
+
@handles << handle
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if service = operation['service']
|
|
162
|
+
raise FormatError, "Service should be a string" unless service.is_a?(String)
|
|
103
163
|
|
|
104
|
-
|
|
164
|
+
begin
|
|
165
|
+
@services << ServiceRecord.new('atproto_pds', 'AtprotoPersonalDataServer', service)
|
|
166
|
+
rescue FormatError
|
|
167
|
+
# ignore services with invalid URIs
|
|
168
|
+
end
|
|
169
|
+
end
|
|
105
170
|
end
|
|
106
171
|
end
|
|
107
172
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base58'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
|
|
7
|
+
module DIDKit
|
|
8
|
+
|
|
9
|
+
#
|
|
10
|
+
# A public signing key of an ATProto account, decoded from its DID document or PLC operation.
|
|
11
|
+
#
|
|
12
|
+
# Both `k256` (`secp256k1`) and `p256` (`secp256r1`, `prime256v1`) keys are supported. Keys from
|
|
13
|
+
# legacy DID documents (before August 2023) without a `did:key:` prefix are intentionally *not* supported.
|
|
14
|
+
#
|
|
15
|
+
# Related documentation:
|
|
16
|
+
# - [ATProto: Cryptography](https://atproto.com/specs/cryptography)
|
|
17
|
+
# - [02, 03 or 04? So What Are Compressed and Uncompressed Public Keys?](https://medium.com/asecuritysite-when-bob-met-alice/02-03-or-04-so-what-are-compressed-and-uncompressed-public-keys-6abcb57efeb6)
|
|
18
|
+
# - [multicodec](https://github.com/multiformats/multicodec) & [multibase](https://github.com/multiformats/multibase)
|
|
19
|
+
#
|
|
20
|
+
|
|
21
|
+
class PublicKey
|
|
22
|
+
|
|
23
|
+
# Multicodec varint prefix for a compressed `k256` public key.
|
|
24
|
+
K256_PREFIX = "\xE7\x01".b.freeze
|
|
25
|
+
|
|
26
|
+
# Multicodec varint prefix for a compressed `p256` public key.
|
|
27
|
+
P256_PREFIX = "\x80\x24".b.freeze
|
|
28
|
+
|
|
29
|
+
# OpenSSL group name for the K-256 curve.
|
|
30
|
+
K256_GROUP_NAME = 'secp256k1'
|
|
31
|
+
|
|
32
|
+
# OpenSSL group name for the P-256 curve.
|
|
33
|
+
P256_GROUP_NAME = 'prime256v1'
|
|
34
|
+
|
|
35
|
+
# @return [String] a 33-byte binary string containing the raw compressed public key
|
|
36
|
+
attr_reader :compressed_key
|
|
37
|
+
|
|
38
|
+
# @return [String] the original multikey value, Base58-encoded with a `z` multibase prefix
|
|
39
|
+
attr_reader :multibase
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Decodes and validates an ATProto account public key.
|
|
43
|
+
#
|
|
44
|
+
# @param string [String] key in the multikey string form, without the `did:key:` prefix
|
|
45
|
+
# @raise [ArgumentError] when the argument is not a string
|
|
46
|
+
# @raise [PublicKeyError] when the key is invalid, incorrectly encoded, or has unsupported type
|
|
47
|
+
|
|
48
|
+
def initialize(string)
|
|
49
|
+
raise ArgumentError, "Expected a string argument" unless string.is_a?(String)
|
|
50
|
+
|
|
51
|
+
# both supported curve prefixes produce 48 Base58 characters, plus the multibase prefix;
|
|
52
|
+
# skip base58 decoding if the passed data is obviously wrong or too long
|
|
53
|
+
raise PublicKeyError, "Unsupported key encoding: #{string.inspect}" unless string.start_with?('z')
|
|
54
|
+
raise PublicKeyError, "Invalid encoded key length (#{string.bytesize})" unless string.bytesize == 49
|
|
55
|
+
|
|
56
|
+
@multibase = string
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
key_decoded = Base58.base58_to_binary(string[1..-1], :bitcoin)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
raise PublicKeyError, "Invalid Base58 encoding in key data"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
prefix = key_decoded.byteslice(0, 2)
|
|
65
|
+
|
|
66
|
+
case prefix
|
|
67
|
+
when K256_PREFIX
|
|
68
|
+
@key_type = :k256
|
|
69
|
+
when P256_PREFIX
|
|
70
|
+
@key_type = :p256
|
|
71
|
+
else
|
|
72
|
+
raise PublicKeyError, "Unsupported key type: #{string.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise PublicKeyError, "Invalid key length (#{key_decoded.bytesize})" unless key_decoded.bytesize == 35
|
|
76
|
+
|
|
77
|
+
@compressed_key = key_decoded.byteslice(2, 33)
|
|
78
|
+
|
|
79
|
+
validate_ec_point
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns a short name of the key's elliptic curve.
|
|
83
|
+
# @return [Symbol] `:k256` or `:p256`
|
|
84
|
+
|
|
85
|
+
def type
|
|
86
|
+
@key_type
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns the OpenSSL elliptic curve group representing the given key's type.
|
|
90
|
+
# @return [OpenSSL::PKey::EC::Group] the `secp256k1` or `prime256v1` group
|
|
91
|
+
|
|
92
|
+
def ec_group
|
|
93
|
+
@ec_group ||= OpenSSL::PKey::EC::Group.new(@key_type == :k256 ? K256_GROUP_NAME : P256_GROUP_NAME)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the OpenSSL object representing the key's point on the elliptic curve.
|
|
97
|
+
#
|
|
98
|
+
# @return [OpenSSL::PKey::EC::Point] elliptic curve point for the key
|
|
99
|
+
|
|
100
|
+
def ec_point
|
|
101
|
+
@ec_point ||= OpenSSL::PKey::EC::Point.new(ec_group, @compressed_key)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def validate_ec_point
|
|
108
|
+
if !ec_point.on_curve?
|
|
109
|
+
# shouldn't generally happen because OpenSSL rejects such keys in Point initializer
|
|
110
|
+
raise PublicKeyError, "Invalid key data: #{@multibase.inspect}"
|
|
111
|
+
end
|
|
112
|
+
rescue OpenSSL::OpenSSLError
|
|
113
|
+
raise PublicKeyError, "Invalid key data: #{@multibase.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/didkit/requests.rb
CHANGED
|
@@ -22,7 +22,6 @@ module DIDKit
|
|
|
22
22
|
timeout = options[:timeout] || 15
|
|
23
23
|
|
|
24
24
|
request_options = {
|
|
25
|
-
use_ssl: true,
|
|
26
25
|
open_timeout: timeout,
|
|
27
26
|
read_timeout: timeout
|
|
28
27
|
}
|
|
@@ -34,13 +33,18 @@ module DIDKit
|
|
|
34
33
|
loop do
|
|
35
34
|
visited_urls << url
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
request_options.update(use_ssl: (url.scheme == 'https'))
|
|
37
|
+
|
|
38
|
+
response = Net::HTTP.start(url.hostname, url.port, request_options) do |http|
|
|
38
39
|
request = Net::HTTP::Get.new(url)
|
|
39
40
|
http.request(request)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
if response.is_a?(Net::HTTPRedirection) && redirects < max_redirects && (location = response['Location'])
|
|
43
|
-
|
|
44
|
+
target = URI.join(url.to_s, location)
|
|
45
|
+
return response unless target.scheme == 'https' || (target.scheme == 'http' && url.scheme == 'http')
|
|
46
|
+
|
|
47
|
+
url = target
|
|
44
48
|
|
|
45
49
|
if visited_urls.include?(url)
|
|
46
50
|
return response
|
|
@@ -54,6 +58,7 @@ module DIDKit
|
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def get_data(url, options = {})
|
|
61
|
+
options = options.dup
|
|
57
62
|
content_type = options.delete(:content_type)
|
|
58
63
|
response = get_response(url, options)
|
|
59
64
|
|
|
@@ -84,13 +89,5 @@ module DIDKit
|
|
|
84
89
|
raise ArgumentError, "Invalid expected_type: #{expected_type.inspect}"
|
|
85
90
|
end
|
|
86
91
|
end
|
|
87
|
-
|
|
88
|
-
# backported from https://github.com/ruby/uri/pull/30/files for older Rubies
|
|
89
|
-
def uri_origin(uri)
|
|
90
|
-
uri = uri.is_a?(URI) ? uri : URI(uri)
|
|
91
|
-
authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
|
|
92
|
-
|
|
93
|
-
"#{uri.scheme}://#{authority}"
|
|
94
|
-
end
|
|
95
92
|
end
|
|
96
93
|
end
|
data/lib/didkit/resolver.rb
CHANGED
|
@@ -1,36 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'ipaddr'
|
|
3
4
|
require 'net/http'
|
|
4
5
|
require 'resolv'
|
|
6
|
+
require 'uri'
|
|
5
7
|
|
|
8
|
+
require_relative 'constants'
|
|
6
9
|
require_relative 'did'
|
|
7
10
|
require_relative 'document'
|
|
8
11
|
require_relative 'requests'
|
|
9
12
|
|
|
10
13
|
module DIDKit
|
|
11
14
|
|
|
15
|
+
class << self
|
|
16
|
+
# Default shared resolver used when calling {DID}/{Document} methods directly.
|
|
17
|
+
# @return [DIDKit::Resolver]
|
|
18
|
+
def default_resolver
|
|
19
|
+
@default_resolver ||= Resolver.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_writer :default_resolver
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
|
|
12
26
|
#
|
|
13
27
|
# A class which manages resolving of handles to DIDs and DIDs to DID documents.
|
|
14
28
|
#
|
|
15
29
|
|
|
16
30
|
class Resolver
|
|
17
|
-
# These TLDs are not allowed in ATProto handles, so the resolver returns nil for them
|
|
18
|
-
# without trying to look them up.
|
|
19
|
-
RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
|
|
20
|
-
|
|
21
31
|
include Requests
|
|
22
32
|
|
|
23
|
-
# @return [String, Array<String
|
|
24
|
-
|
|
33
|
+
# @return [String, Array<String>, nil] custom DNS nameserver(s) to use for DNS TXT lookups
|
|
34
|
+
attr_reader :nameserver
|
|
25
35
|
|
|
26
36
|
# @param options [Hash] resolver options
|
|
27
37
|
# @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs)
|
|
38
|
+
# @option options [String] :plc_directory URL or host[:port] of an alternate PLC directory,
|
|
39
|
+
# without a path; defaults to HTTPS (HTTP allowed for localhost/loopback IPs only)
|
|
28
40
|
# @option options [Integer] :timeout request timeout in seconds (default: 15)
|
|
29
41
|
# @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
|
|
30
|
-
|
|
42
|
+
# @raise [ArgumentError] if the PLC directory address is invalid
|
|
43
|
+
#
|
|
31
44
|
def initialize(options = {})
|
|
32
45
|
@nameserver = options[:nameserver]
|
|
33
|
-
@
|
|
46
|
+
@plc_service = options.key?(:plc_directory) ? parse_plc_service(options[:plc_directory]) : DEFAULT_PLC_DIRECTORY
|
|
47
|
+
@request_options = options.slice(:timeout, :max_redirects).freeze
|
|
34
48
|
end
|
|
35
49
|
|
|
36
50
|
# Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
|
|
@@ -41,46 +55,56 @@ module DIDKit
|
|
|
41
55
|
# check which one it is.
|
|
42
56
|
#
|
|
43
57
|
# @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
|
|
58
|
+
# @param verify [Boolean] require the handle to be assigned to the DID in the DID document (default: true)
|
|
44
59
|
# @return [DID, nil] resolved DID if found, nil otherwise
|
|
60
|
+
# @raise [APIError] if verification is enabled and the DID document request is unsuccessful
|
|
61
|
+
# @raise [FormatError] if verification is enabled and the DID document has invalid data
|
|
45
62
|
|
|
46
|
-
def resolve_handle(handle)
|
|
47
|
-
if handle.is_a?(DID) || handle
|
|
48
|
-
|
|
63
|
+
def resolve_handle(handle, verify: true)
|
|
64
|
+
if !(handle.is_a?(DID) || handle.is_a?(String))
|
|
65
|
+
raise ArgumentError, "handle argument should be a String or a DID object"
|
|
49
66
|
end
|
|
50
67
|
|
|
51
|
-
|
|
68
|
+
if handle.is_a?(DID) || handle =~ DIDKit::GENERIC_DID_REGEXP
|
|
69
|
+
return DID.new(handle)
|
|
70
|
+
end
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
domain = handle.gsub(/^@/, '').downcase(:ascii)
|
|
73
|
+
return nil unless valid_handle_identifier?(domain)
|
|
54
74
|
|
|
55
75
|
if dns_did = resolve_handle_by_dns(domain)
|
|
56
|
-
DID.new(dns_did, :dns)
|
|
76
|
+
referenced_did = DID.new(dns_did, :dns)
|
|
57
77
|
elsif http_did = resolve_handle_by_well_known(domain)
|
|
58
|
-
DID.new(http_did, :http)
|
|
78
|
+
referenced_did = DID.new(http_did, :http)
|
|
59
79
|
else
|
|
60
|
-
nil
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if verify
|
|
84
|
+
document = self.resolve_did(referenced_did)
|
|
85
|
+
return nil unless document.handles.first&.downcase == domain
|
|
61
86
|
end
|
|
87
|
+
|
|
88
|
+
referenced_did
|
|
62
89
|
end
|
|
63
90
|
|
|
64
91
|
# Tries to resolve a handle into DID using the DNS TXT method.
|
|
65
92
|
#
|
|
66
93
|
# Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is
|
|
67
|
-
# a correct DID string.
|
|
94
|
+
# a correct DID string. Records are rejected if there exists more than one valid entry and they
|
|
95
|
+
# are referencing two or more different DIDs.
|
|
68
96
|
#
|
|
69
97
|
# @param domain [String] a domain handle to look up
|
|
70
|
-
# @return [String, nil] resolved DID if found, nil otherwise
|
|
98
|
+
# @return [String, nil] resolved DID if one valid record is found, nil otherwise
|
|
71
99
|
|
|
72
100
|
def resolve_handle_by_dns(domain)
|
|
73
101
|
dns_records = Resolv::DNS.open(resolv_options) do |d|
|
|
74
102
|
d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
|
|
75
103
|
end
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
if string = record.strings.first
|
|
79
|
-
return parse_did_from_dns(string)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
105
|
+
dids = dns_records.map { |record| parse_did_from_dns(record.data) }.compact.uniq
|
|
82
106
|
|
|
83
|
-
nil
|
|
107
|
+
dids.length == 1 ? dids.first : nil
|
|
84
108
|
end
|
|
85
109
|
|
|
86
110
|
# Tries to resolve a handle into DID using the HTTP .well-known method.
|
|
@@ -100,14 +124,15 @@ module DIDKit
|
|
|
100
124
|
end
|
|
101
125
|
|
|
102
126
|
nil
|
|
103
|
-
rescue StandardError
|
|
127
|
+
rescue StandardError
|
|
104
128
|
nil
|
|
105
129
|
end
|
|
106
130
|
|
|
107
131
|
# Resolve a DID to a DID document.
|
|
108
132
|
#
|
|
109
133
|
# Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
|
|
110
|
-
# [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain
|
|
134
|
+
# [plc.directory](https://plc.directory) or its mirror for did:plc DIDs, or the did:web's domain
|
|
135
|
+
# for did:web DIDs.
|
|
111
136
|
#
|
|
112
137
|
# @param did [String, DID] DID string or object
|
|
113
138
|
# @return [Document] resolved DID document
|
|
@@ -119,11 +144,11 @@ module DIDKit
|
|
|
119
144
|
did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
|
|
120
145
|
end
|
|
121
146
|
|
|
122
|
-
# Returns the
|
|
147
|
+
# Returns the current primary handle assigned to the given DID, if it verifies correctly.
|
|
123
148
|
#
|
|
124
|
-
# Looks up the
|
|
125
|
-
#
|
|
126
|
-
#
|
|
149
|
+
# Looks up the first syntactically valid handle in the DID document and checks if it is assigned
|
|
150
|
+
# correctly to this DID using DNS TXT or .well-known. Any later handles are ignored, even if the
|
|
151
|
+
# first handle fails verification. Returns nil if the primary handle cannot be verified.
|
|
127
152
|
#
|
|
128
153
|
# @param subject [String, DID, Document] a DID or its DID document
|
|
129
154
|
# @return [String, nil] verified handle domain, if found
|
|
@@ -131,44 +156,87 @@ module DIDKit
|
|
|
131
156
|
def get_verified_handle(subject)
|
|
132
157
|
document = subject.is_a?(Document) ? subject : resolve_did(subject)
|
|
133
158
|
|
|
134
|
-
|
|
135
|
-
end
|
|
159
|
+
primary_handle = document.handles.first
|
|
136
160
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# @param did [DID, String] DID to verify the handles against
|
|
140
|
-
# @param handles [Array<String>] handles to check
|
|
141
|
-
# @return [String, nil] a verified handle, if found
|
|
161
|
+
return nil if primary_handle.nil?
|
|
162
|
+
return nil unless resolve_handle(primary_handle, verify: false) == document.did.to_s
|
|
142
163
|
|
|
143
|
-
|
|
144
|
-
handles.detect { |h| resolve_handle(h) == did.to_s }
|
|
164
|
+
primary_handle
|
|
145
165
|
end
|
|
146
166
|
|
|
147
167
|
|
|
148
168
|
private
|
|
149
169
|
|
|
170
|
+
def valid_handle_identifier?(handle)
|
|
171
|
+
return false unless handle =~ DIDKit::HANDLE_REGEXP && handle.length <= DIDKit::MAX_HANDLE_LENGTH
|
|
172
|
+
return false if DIDKit::RESERVED_DOMAINS.include?(handle.split('.').last)
|
|
173
|
+
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
150
177
|
def resolv_options
|
|
151
178
|
options = Resolv::DNS::Config.default_config_hash.dup
|
|
152
179
|
options[:nameserver] = nameserver if nameserver
|
|
153
180
|
options
|
|
154
181
|
end
|
|
155
182
|
|
|
183
|
+
def parse_plc_service(url)
|
|
184
|
+
raise ArgumentError, 'PLC directory URL must be a string' unless url.is_a?(String)
|
|
185
|
+
|
|
186
|
+
url = "https://#{url}" unless url.include?('://')
|
|
187
|
+
uri = URI.parse(url.chomp('/'))
|
|
188
|
+
|
|
189
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty? && uri.path.empty?
|
|
190
|
+
raise ArgumentError, 'PLC directory must be a hostname or a HTTP(S) URL without a path'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
unless !uri.userinfo && !uri.query && !uri.fragment
|
|
194
|
+
raise ArgumentError, 'PLC directory URL must not include user credentials, query, or fragment part'
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if uri.scheme == 'http' && !loopback_host?(uri.hostname)
|
|
198
|
+
raise ArgumentError, 'http:// URLs are allowed only with localhost or a loopback IP'
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
uri.to_s
|
|
202
|
+
rescue URI::InvalidURIError
|
|
203
|
+
raise ArgumentError, 'Invalid PLC directory URL'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def loopback_host?(host)
|
|
207
|
+
host.downcase(:ascii) == 'localhost' || IPAddr.new(host).loopback?
|
|
208
|
+
rescue IPAddr::InvalidAddressError
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def valid_did?(did)
|
|
213
|
+
DID.new(did)
|
|
214
|
+
true
|
|
215
|
+
rescue DIDError
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
156
219
|
def parse_did_from_dns(txt)
|
|
157
|
-
|
|
220
|
+
return nil unless txt.start_with?('did=')
|
|
221
|
+
|
|
222
|
+
did = txt.delete_prefix('did=')
|
|
223
|
+
|
|
224
|
+
valid_did?(did) ? did : nil
|
|
158
225
|
end
|
|
159
226
|
|
|
160
227
|
def parse_did_from_well_known(text)
|
|
161
228
|
text = text.strip
|
|
162
|
-
text.lines.length == 1 && text
|
|
229
|
+
text.lines.length == 1 && valid_did?(text) ? text : nil
|
|
163
230
|
end
|
|
164
231
|
|
|
165
232
|
def resolve_did_plc(did)
|
|
166
|
-
|
|
233
|
+
options = @request_options.merge(content_type: /^application\/did(\+ld)?\+json(;.+)?$/)
|
|
234
|
+
json = get_json("#{@plc_service}/#{did}", options)
|
|
167
235
|
Document.new(did, json)
|
|
168
236
|
end
|
|
169
237
|
|
|
170
238
|
def resolve_did_web(did)
|
|
171
|
-
json = get_json("
|
|
239
|
+
json = get_json("#{did.did_web_host}/.well-known/did.json", @request_options)
|
|
172
240
|
Document.new(did, json)
|
|
173
241
|
end
|
|
174
242
|
end
|