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.
@@ -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 once new APIs are deployed to plc.directory.
13
- # Things will change here in v. 0.4.
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 :ignore_errors, :last_date, :error_handler
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
- def ignore_errors=(val)
45
- @ignore_errors = val
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("https://#{plc_service}/export")
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("https://#{plc_service}/#{did}/log/audit", :content_type => :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.filter_map { |json|
74
- begin
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
- @last_date = operations.last&.created_at || request_time
88
- @last_page_cids = Set.new(operations.map(&:cid))
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
@@ -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 [String] the operation type
39
+ # @return [Symbol] the operation type
39
40
  attr_reader :type
40
41
 
41
- # Returns a list of handles assigned to the DID in this operation.
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
- services = operation['services']
90
- raise FormatError, "Missing services key: #{json}" if services.nil?
91
- raise FormatError, "Invalid services data: #{services.inspect}" unless services.is_a?(Hash)
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
- @services = services.map { |k, x|
94
- type, endpoint = x.values_at('type', 'endpoint')
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
- ServiceRecord.new(k, type, endpoint)
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
- @handles = parse_also_known_as(operation['alsoKnownAs'])
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
@@ -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
- response = Net::HTTP.start(url.host, url.port, request_options) do |http|
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
- url = URI(location.include?('://') ? location : (uri_origin(url) + location))
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
@@ -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>] custom DNS nameserver(s) to use for DNS TXT lookups
24
- attr_accessor :nameserver
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
- @request_options = options.slice(:timeout, :max_redirects)
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 =~ DID::GENERIC_REGEXP
48
- return DID.new(handle)
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
- domain = handle.gsub(/^@/, '')
68
+ if handle.is_a?(DID) || handle =~ DIDKit::GENERIC_DID_REGEXP
69
+ return DID.new(handle)
70
+ end
52
71
 
53
- return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
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
- if record = dns_records.first
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 => e
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 for did:web DIDs.
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 first verified handle assigned to the given DID.
147
+ # Returns the current primary handle assigned to the given DID, if it verifies correctly.
123
148
  #
124
- # Looks up the domain handles assigned to the DID in the DID document, checks if they are
125
- # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
126
- # the first handle that validates correctly, or nil if none matches.
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
- first_verified_handle(document.did, document.handles)
135
- end
159
+ primary_handle = document.handles.first
136
160
 
137
- # Returns the first handle from the list that resolves back to the given DID.
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
- def first_verified_handle(did, handles)
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
- txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil
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 =~ DID::GENERIC_REGEXP ? text : nil
229
+ text.lines.length == 1 && valid_did?(text) ? text : nil
163
230
  end
164
231
 
165
232
  def resolve_did_plc(did)
166
- json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/)
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("https://#{did.web_domain}/.well-known/did.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