didkit 0.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 505c6714968219d931f33b5bf4eb42f90f12b6c4391227b4257a5b4063491128
4
- data.tar.gz: 3e877922065e457bb1fe706db1259b8ddd741cacdc7faa7f17ea3adeb4b23faa
3
+ metadata.gz: cf656ddc5c0b21d616657dc8f7d310c1f5aba7b77828542a1871c05d177ecbc0
4
+ data.tar.gz: b23f42ec6c434fa1ba7867976310fac7cfa006c0d093ef105141c0cd5c8b8543
5
5
  SHA512:
6
- metadata.gz: 587c899080bdb947261b54b2bbe2b3263567cb9aecca682086149d3a0ca4692a443a217b42520caca3e24ec246a5e3a0ea058d86c903cadb13075c2ebbac0291
7
- data.tar.gz: c76d96e9fda51a31d87a7e15f93549e9594d8651d7668d77a38b9fe0bff09631d78a2d3a34f0afbd6cd9506a0208e9ece7617ebf1128fca7d1ab7d27896a5ea3
6
+ metadata.gz: c0227bd5bad67bba3086ac4cc3f766d8976cfd0f4d6a882039676b9792a9c1bcb8a9dd69015918b7c5100613d0689a3460fa0350a25c0ab63a1c54b7e3df7c22
7
+ data.tar.gz: '017428fc8d8cae144b57029f1547615788ab9fb097ab45403803e94e61954c72511cdf68412c053da40437ba6bdd0f3be86990146dcd142389167ebd4febd84e'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## [0.5.0] - 2026-10-26
2
+
3
+ Breaking changes:
4
+
5
+ - the [ATProto DID spec](https://atproto.com/specs/did#did-documents) now says that if there are multiple handles in a document, only the first one should be checked bi-directionally and the rest should be skipped:
6
+
7
+ > The first syntactically valid handle found in the ordered list is treated as the claimed handle, even if it fails to resolve bi-directionally. Any other handle URIs should be ignored.
8
+
9
+ - the `get_verified_handle` algorithm has been updated to match this behavior
10
+ - `Resolver#first_verified_handle` method has been removed
11
+ - `DIDKit::PLCImporter::PLC_SERVICE` was moved to `DIDKit::DEFAULT_PLC_DIRECTORY`
12
+ - `DIDKit::DID::GENERIC_REGEXP` was moved to `DIDKit::GENERIC_DID_REGEXP`
13
+ - `DIDKit::Resolver#nameserver` has been changed to be read-only
14
+ - `DIDKit::Resolver#resolve_handle` and `DID.resolve_handle` now verify the resolve handle bi-directionally, checking also if the matched DID has that handle assigned too; pass `verify: false` to skip this (default is true). This call can now raise an error if the DID document cannot be fetched.
15
+
16
+ Added:
17
+
18
+ - added support for decoding public (signing) keys – `Document#signing_key`, `PLCOperation#signing_key` and `DIDKit::PublicKey` class
19
+ - `DIDKit::Resolver` now accepts a `:plc_directory` option that lets you use a PLC mirror server instead of `https://plc.directory`
20
+ - added a shared global `DIDKit.default_resolver`, used internally by `DID` & `Document` – you can reassign it to one configured with a custom DNS nameserver or PLC mirror
21
+ - added support for PLC servers returning `application/did+json` content type instead of `application/did+ld+json` (the spec allows it and the replica server implementation does that)
22
+ - added support for PLC servers returning services with IDs in the absolute format `did:...#id` instead of just `#id`
23
+ - added `DID#did_web_host`, which returns the did:web host URL together with `http(s)://` and a port if included (only for `localhost`), and raises a `DIDError` if the DID is not a did:web; `#web_domain` renamed to `#did_web_domain` and kept with the old behavior, `#web_domain` added as an alias
24
+
25
+ Other:
26
+
27
+ - more detailed validation of DID & handle strings and for service endpoint URLs
28
+ - fixed DNS-based handle verification if there are multiple text chunks in a TXT record or multiple TXT records
29
+ - make sure that handle verification is case-insensitive
30
+ - the global `DID` alias is not reassigned if something else already defines it before DIDKit is loaded
31
+ - fixed redirects to relative URLs, and disallowed redirects to non-HTTP URLs and from HTTPS to HTTP
32
+ - fixed `:max_redirects` & `:timeout` options not being used when loading a DID document
33
+
1
34
  ## [0.4.0] - 2026-07-02
2
35
 
3
36
  - added `#also_known_as` separate from `#handles` in `Document` and `PLCOperation`, which stores the original array of strings as listed in the JSON, not filtered and not stripped of the `at://` prefix
@@ -51,8 +84,6 @@ Also:
51
84
  - `PLCImporter` now removes duplicate operations at the edge of pages returned from the `/export` API
52
85
  - rewritten some networking code – all classes now use `Net::HTTP` with consistent options instead of `open-uri`
53
86
 
54
- Note: `PLCImporter` will be rewritten soon to add support for updated [plc.directory](https://plc.directory) APIs, so be prepared for some breaking changes there in v. 0.4.
55
-
56
87
  ## [0.2.3] - 2024-07-02
57
88
 
58
89
  - added a `DID#get_audit_log` method that fetches the PLC audit log for a DID
data/README.md CHANGED
@@ -15,7 +15,7 @@ Accounts on Bluesky use identifiers like [did:plc:oio4hkxaop4ao4wz2pp3f4cr](http
15
15
 
16
16
  ## Installation
17
17
 
18
- To use DIDKit, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
18
+ To use DIDKit, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.3+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
19
19
 
20
20
  To install the gem, run in the command line:
21
21
 
@@ -23,7 +23,7 @@ To install the gem, run in the command line:
23
23
 
24
24
  Or add this to your app's `Gemfile`:
25
25
 
26
- gem 'didkit', '~> 0.3'
26
+ gem 'didkit', '~> 0.5'
27
27
 
28
28
 
29
29
  ## Usage
@@ -42,6 +42,8 @@ This returns a `DID` object, which tells you:
42
42
  - the DID type (`#type`, `:plc` or `:web`)
43
43
  - if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
44
44
 
45
+ The handle is automatically verified bi-directionally, i.e. the resolver loads the DID document for e.g. `did:plc:oky5czdrnfjpqslsw2a5iclo` and checks if the handle `jay.bsky.team` is listed there under `alsoKnownAs`. If you want to skip that second check, pass `verify: false` to `#resolve_handle`.
46
+
45
47
  To go in the other direction – to find an assigned and verified handle given a DID – create a `DID` from a DID string and call `get_verified_handle`:
46
48
 
47
49
  ```rb
@@ -89,6 +91,7 @@ You can customize some things about the DID/handle lookups by using the `DIDKit:
89
91
  Currently available options include:
90
92
 
91
93
  - `:nameserver` – override the nameserver used for DNS lookups, e.g. to use Google's or CloudFlare's DNS
94
+ - `:plc_directory` – hostname or URL of a PLC directory mirror to use instead of `plc.directory`
92
95
  - `:timeout` – change the connection/response timeout for HTTP requests (default: 15 s)
93
96
  - `:max_redirects` – change allowed maximum number of redirects (default: 5)
94
97
 
@@ -108,6 +111,24 @@ resolver.get_verified_handle(did)
108
111
  # => 'nytimes.com'
109
112
  ```
110
113
 
114
+ `DID` and `Document` use a shared global resolver instance at `DIDKit.default_resolver`, which you can overwrite with one using a custom config:
115
+
116
+ ```rb
117
+ DIDKit.default_resolver = DIDKit::Resolver.new(plc_directory: 'plc.eurosky.network')
118
+
119
+ did = DID.resolve_handle('mackuba.eu')
120
+ puts did.document.pds_host
121
+ # uses plc.eurosky.network to look up DID info
122
+ ```
123
+
124
+ More info in the [YARD API docs](https://rubydoc.info/gems/didkit).
125
+
126
+
127
+ ### Global DID alias
128
+
129
+ As mentioned earlier, DIDKit defines a global alias `DID` as a shorthand for `DIDKit::DID`. If this happens to conflict with another constant defined by your app or a different dependency, make sure to `require 'didkit'` *after* that constant is already defined, or alternatively, require e.g. `'didkit/did'` directly, which skips the `global_did.rb` file which defines the global `DID`.
130
+
131
+
111
132
  ## Other resources
112
133
 
113
134
  - [YARD API documentation](https://rubydoc.info/gems/didkit) at rubydoc.info
data/SECURITY.md ADDED
@@ -0,0 +1,16 @@
1
+ # Security Policy
2
+
3
+ ## How To Report a Vulnerability
4
+
5
+ If you find a potential vulnerability in one of my projects, it's recommended not to report it through a standard publicly-readable issue report, in order to avoid giving possible clues to any potential attackers who could abuse it before it's fixed (see: ["Just a rumour of a bug is enough to find a security exploit these days"](https://anil.recoil.org/notes/rumour-is-the-exploit)).
6
+
7
+ You can use one of these to contact me instead:
8
+
9
+ - [@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr) on Bluesky (my DMs should be open, or mention me discretely with a request for contact)
10
+ - [@mackuba@martianbase.net](https://martianbase.net/@mackuba) on Mastodon (my personal instance)
11
+ - email: mackuba `@` protonmail.ch (optionally with [this PGP key](https://mail-api.proton.me/pks/lookup?op=get&search=mackuba@protonmail.ch))
12
+ - ["Report a vulnerability"](https://github.com/mackuba/didkit/security/advisories/new) form on GitHub
13
+
14
+ I do not check my Twitter/X or LinkedIn accounts regularly.
15
+
16
+ Note: please don't submit AI-reported issues that have not been verified manually :)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'constants'
3
4
  require_relative 'errors'
4
5
 
5
6
  module DIDKit
@@ -13,7 +14,11 @@ module DIDKit
13
14
  # Returns a list of ATProto handles assigned to this DID in its DID document.
14
15
  #
15
16
  # Note: the handles aren't guaranteed to be verified (validated in the other direction).
16
- # Use {DID#get_verified_handle} to find a handle that is correctly verified.
17
+ # Use {DID#get_verified_handle} to get a handle that is correctly verified.
18
+ #
19
+ # According to the current [ATProto DID spec](https://atproto.com/specs/did#did-documents),
20
+ # if a DID has more than one handle assigned, only the first one on the list should be
21
+ # considered and the rest should be ignored.
17
22
  #
18
23
  # @api public
19
24
  # @return [Array<String>]
@@ -37,7 +42,13 @@ module DIDKit
37
42
  raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.all? { |x| x.is_a?(String) }
38
43
 
39
44
  @also_known_as = aka
40
- @handles = aka.select { |x| x =~ %r(\Aat://[^/]+\z) }.map { |x| x.gsub('at://', '') }
45
+ @handles = extract_handles_from_aka(aka)
46
+ end
47
+
48
+ def extract_handles_from_aka(aka)
49
+ handles = aka.select { |x| x.start_with?('at://') }.map { |x| x.delete_prefix('at://') }
50
+
51
+ handles.select { |h| h =~ DIDKit::HANDLE_REGEXP && h.length <= DIDKit::MAX_HANDLE_LENGTH }
41
52
  end
42
53
  end
43
54
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DIDKit
4
+ # URL of the standard PLC directory service.
5
+ DEFAULT_PLC_DIRECTORY = 'https://plc.directory'
6
+
7
+ DOMAIN_SEGMENT = /[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?/
8
+ LAST_DOMAIN_SEGMENT = /[a-zA-Z]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?/
9
+ private_constant :DOMAIN_SEGMENT, :LAST_DOMAIN_SEGMENT
10
+
11
+ # Pattern that matches any valid ATProto handle (without @)
12
+ HANDLE_REGEXP = /\A(#{DOMAIN_SEGMENT}\.)+#{LAST_DOMAIN_SEGMENT}\z/
13
+
14
+ # Pattern that matches any valid did:plc DID
15
+ DID_PLC_REGEXP = /\Adid\:plc\:[abcdefghijklmnopqrstuvwxyz234567]{24}\z/
16
+
17
+ # Pattern that matches a percent-decoded did:web DID
18
+ DID_WEB_REGEXP = /\Adid\:web\:((#{DOMAIN_SEGMENT}\.)+#{LAST_DOMAIN_SEGMENT}|localhost(:[1-9][0-9]{0,4})?)\z/
19
+
20
+ # Pattern that matches any valid DID (including methods unsupported in ATProto)
21
+ GENERIC_DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9_\.\:\%\-]*[a-zA-Z0-9_\.\-]\z/
22
+
23
+ # Maximum allowed DID length in ATProto
24
+ MAX_DID_LENGTH = 2048
25
+
26
+ # Maximum allowed handle length in ATProto
27
+ MAX_HANDLE_LENGTH = 253
28
+
29
+ # TLDs that are not allowed in ATProto handles
30
+ RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
31
+ end
data/lib/didkit/did.rb CHANGED
@@ -3,7 +3,9 @@
3
3
  require 'json'
4
4
  require 'uri'
5
5
 
6
+ require_relative 'constants'
6
7
  require_relative 'errors'
8
+ require_relative 'plc_importer'
7
9
  require_relative 'requests'
8
10
  require_relative 'resolver'
9
11
 
@@ -19,8 +21,6 @@ module DIDKit
19
21
  #
20
22
 
21
23
  class DID
22
- GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
23
-
24
24
  include Requests
25
25
 
26
26
  # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
@@ -31,10 +31,13 @@ module DIDKit
31
31
  # check which one it is.
32
32
  #
33
33
  # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
34
+ # @param verify [Boolean] require the handle to be assigned to the DID in the DID document (default: true)
34
35
  # @return [DID, nil] resolved DID if found, nil otherwise
36
+ # @raise [APIError] if verification is enabled and the DID document request is unsuccessful
37
+ # @raise [FormatError] if verification is enabled and the DID document has invalid data
35
38
 
36
- def self.resolve_handle(handle)
37
- Resolver.new.resolve_handle(handle)
39
+ def self.resolve_handle(handle, verify: true)
40
+ DIDKit.default_resolver.resolve_handle(handle, verify: verify)
38
41
  end
39
42
 
40
43
  # @return [Symbol] DID type (`:plc` or `:web`)
@@ -60,14 +63,18 @@ module DIDKit
60
63
  did = did.to_s
61
64
  end
62
65
 
63
- if did =~ GENERIC_REGEXP
64
- @did = did
65
- @type = did.split(':')[1].to_sym
66
- else
67
- raise DIDError.new("Invalid DID format")
68
- end
66
+ raise ArgumentError.new("did should be a String or a DID object") unless did.is_a?(String)
67
+ raise DIDError.new("Invalid DID format") unless did =~ DIDKit::GENERIC_DID_REGEXP
68
+ raise DIDError.new("DID string too long") if did.length > DIDKit::MAX_DID_LENGTH
69
+
70
+ @did = did
71
+ @type = did.split(':')[1].to_sym
69
72
 
70
- if @type != :plc && @type != :web
73
+ if @type == :plc
74
+ validate_did_plc(did)
75
+ elsif @type == :web
76
+ validate_did_web(did)
77
+ else
71
78
  raise DIDError.new("Unrecognized DID type: #{@type}")
72
79
  end
73
80
 
@@ -87,19 +94,19 @@ module DIDKit
87
94
  # @return [Document] resolved DID document
88
95
 
89
96
  def get_document
90
- Resolver.new.resolve_did(self)
97
+ DIDKit.default_resolver.resolve_did(self)
91
98
  end
92
99
 
93
- # Returns the first verified handle assigned to this DID.
100
+ # Returns the current primary handle assigned to the DID, if it verifies correctly.
94
101
  #
95
- # Looks up the domain handles assigned to this DID in its DID document, checks if they are
96
- # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
97
- # the first handle that validates correctly, or nil if none matches.
102
+ # Looks up the first syntactically valid handle in the DID document and checks if it is assigned
103
+ # correctly to this DID using DNS TXT or .well-known. Any later handles are ignored, even if the
104
+ # first handle fails verification. Returns nil if the primary handle cannot be verified.
98
105
  #
99
106
  # @return [String, nil] verified handle domain, if found
100
107
 
101
108
  def get_verified_handle
102
- Resolver.new.get_verified_handle(document)
109
+ DIDKit.default_resolver.get_verified_handle(document)
103
110
  end
104
111
 
105
112
  # Fetches the PLC audit log (list of all previous operations) for a did:plc DID.
@@ -115,12 +122,31 @@ module DIDKit
115
122
  end
116
123
  end
117
124
 
118
- # Returns the domain portion of a did:web identifier.
125
+ # Returns the domain portion of a did:web identifier. If the DID uses `localhost` with port as the hostname,
126
+ # the port part is skipped.
119
127
  #
120
128
  # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc
121
129
 
122
- def web_domain
123
- did.gsub(/^did\:web\:/, '') if type == :web
130
+ def did_web_domain
131
+ return nil unless type == :web
132
+
133
+ @did_web_host.gsub(/:.*/, '')
134
+ end
135
+
136
+ alias web_domain did_web_domain
137
+
138
+ # Returns the URL of the host that hosts a did:web identity, including the http/https scheme
139
+ # and the port if included.
140
+ #
141
+ # @return [String] root URL of the host referenced by the did:web domain
142
+ # @raise [DIDError] when the DID is not a did:web
143
+
144
+ def did_web_host
145
+ raise DIDError, "DID is not a did:web" unless type == :web
146
+
147
+ protocol = (@did_web_host =~ /^localhost(\:.*|$)/) ? 'http' : 'https'
148
+
149
+ "#{protocol}://#{@did_web_host}"
124
150
  end
125
151
 
126
152
  # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint.
@@ -136,8 +162,7 @@ module DIDKit
136
162
  doc = self.document
137
163
  return nil if doc.pds_endpoint.nil?
138
164
 
139
- pds_host = uri_origin(doc.pds_endpoint)
140
- url = URI("#{pds_host}/xrpc/com.atproto.sync.getRepoStatus")
165
+ url = URI.join(doc.pds_endpoint, "/xrpc/com.atproto.sync.getRepoStatus")
141
166
  url.query = URI.encode_www_form(:did => @did)
142
167
 
143
168
  response = get_response(url, request_options)
@@ -197,5 +222,34 @@ module DIDKit
197
222
  false
198
223
  end
199
224
  end
225
+
226
+
227
+ private
228
+
229
+ def validate_did_plc(did)
230
+ raise DIDError.new("Invalid did:plc") unless did =~ DIDKit::DID_PLC_REGEXP
231
+ end
232
+
233
+ def validate_did_web(did)
234
+ identifier_part = did.delete_prefix('did:web:')
235
+
236
+ # literal colons delimit paths in did:web - a port separator must be percent-encoded
237
+ raise DIDError.new("Invalid did:web") if identifier_part.include?(':')
238
+
239
+ decoded_host = URI::DEFAULT_PARSER.unescape(identifier_part).b
240
+ raise DIDError.new("Invalid did:web") unless "did:web:#{decoded_host}" =~ DIDKit::DID_WEB_REGEXP
241
+
242
+ if decoded_host.include?(':')
243
+ hostname, port = decoded_host.split(':')
244
+ raise DIDError.new("Invalid did:web server port") unless port.to_i.between?(1, 65535)
245
+ else
246
+ hostname = decoded_host
247
+ end
248
+
249
+ tld = hostname.split('.').last.downcase
250
+ raise DIDError.new("Invalid did:web TLD: #{tld}") if RESERVED_DOMAINS.include?(tld) && hostname != 'localhost'
251
+
252
+ @did_web_host = decoded_host
253
+ end
200
254
  end
201
255
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'at_handles'
4
4
  require_relative 'errors'
5
+ require_relative 'public_key'
5
6
  require_relative 'resolver'
6
7
  require_relative 'service_record'
7
8
  require_relative 'services'
@@ -9,22 +10,29 @@ require_relative 'services'
9
10
  module DIDKit
10
11
 
11
12
  #
12
- # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain.
13
+ # Parsed DID document from a JSON file loaded from the PLC directory ([plc.directory](https://plc.directory)
14
+ # or a mirror) or from a did:web domain.
13
15
  #
14
16
  # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object.
15
17
  #
18
+ # Related specifications:
19
+ # - [ATProto DID](https://atproto.com/specs/did)
20
+ #
16
21
 
17
22
  class Document
18
23
  include AtHandles
19
24
  include Services
20
25
 
26
+ SERVICE_REGEXP = /\A(did:[^#]+)?#[^#]+\z/
27
+ private_constant :SERVICE_REGEXP
28
+
21
29
  # @return [Hash] the complete JSON data of the DID document
22
30
  attr_reader :json
23
31
 
24
32
  # @return [DID] the DID that this document describes
25
33
  attr_reader :did
26
34
 
27
- # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
35
+ # @return [Array<ServiceRecord>] service records like PDS details assigned to the DID
28
36
  attr_reader :services
29
37
 
30
38
  # Creates a DID document object.
@@ -34,6 +42,9 @@ module DIDKit
34
42
  # @raise [FormatError] when required fields are missing or invalid.
35
43
 
36
44
  def initialize(did, json)
45
+ raise ArgumentError, "did should be a DID object" unless did.is_a?(DID)
46
+ raise ArgumentError, "json should be a Hash" unless json.is_a?(Hash)
47
+
37
48
  raise FormatError, "Missing id field" if json['id'].nil?
38
49
  raise FormatError, "Invalid id field" unless json['id'].is_a?(String)
39
50
  raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s
@@ -45,16 +56,42 @@ module DIDKit
45
56
  parse_also_known_as(json['alsoKnownAs'] || [])
46
57
  end
47
58
 
48
- # Returns the first verified handle assigned to the DID.
59
+ # Returns the current primary handle assigned to the DID, if it verifies correctly.
49
60
  #
50
- # Looks up the domain handles assigned to this DID in the DID document, checks if they are
51
- # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
52
- # the first handle that validates correctly, or nil if none matches.
61
+ # Looks up the first syntactically valid handle in the DID document and checks if it is assigned
62
+ # correctly to this DID using DNS TXT or .well-known. Any later handles are ignored, even if the
63
+ # first handle fails verification. Returns nil if the primary handle cannot be verified.
53
64
  #
54
65
  # @return [String, nil] verified handle domain, if found
55
66
 
56
67
  def get_verified_handle
57
- Resolver.new.get_verified_handle(self)
68
+ DIDKit.default_resolver.get_verified_handle(self)
69
+ end
70
+
71
+ # Returns the account's public signing key parsed from the DID document's `verificationMethod`.
72
+ #
73
+ # @return [PublicKey, nil] decoded signing key wrapped in {PublicKey}, or nil if no matching verification
74
+ # method is found
75
+ # @raise [FormatError] when `verificationMethod` or one of its entries has an invalid type
76
+ # @raise [ArgumentError] when selected key data is not a string
77
+ # @raise [PublicKeyError] when the key is invalid, incorrectly encoded, or has unsupported type
78
+
79
+ def signing_key
80
+ keys = @json['verificationMethod']
81
+ return nil if keys.nil?
82
+
83
+ raise FormatError, "verificationMethod is not an array: #{keys.inspect}" unless keys.is_a?(Array)
84
+
85
+ keys.each do |data|
86
+ raise FormatError, "verificationMethod item is invalid: #{data.inspect}" unless data.is_a?(Hash)
87
+
88
+ next unless data['id'] == "#{@did}#atproto" || data['id'] == "#atproto"
89
+ next unless data['controller'] == @did.to_s && data['type'] == 'Multikey' && !data['publicKeyMultibase'].nil?
90
+
91
+ return PublicKey.new(data['publicKeyMultibase'])
92
+ end
93
+
94
+ nil
58
95
  end
59
96
 
60
97
 
@@ -70,7 +107,16 @@ module DIDKit
70
107
 
71
108
  raise FormatError, "Missing service id" unless id
72
109
  raise FormatError, "Invalid service id: #{id.inspect}" unless id.is_a?(String)
73
- next if !id.start_with?('#')
110
+ next unless id =~ SERVICE_REGEXP
111
+
112
+ if id.start_with?('did:')
113
+ did, rest = id.split('#')
114
+ next unless did == @did.to_s
115
+
116
+ id = rest
117
+ else
118
+ id = id.gsub(/^#/, '')
119
+ end
74
120
 
75
121
  raise FormatError, "Missing service type" unless type
76
122
  raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
@@ -79,7 +125,7 @@ module DIDKit
79
125
  raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
80
126
 
81
127
  begin
82
- @services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
128
+ @services << ServiceRecord.new(id, type, endpoint)
83
129
  rescue FormatError => e
84
130
  # ignore services with invalid URIs
85
131
  end
data/lib/didkit/errors.rb CHANGED
@@ -38,4 +38,10 @@ module DIDKit
38
38
  #
39
39
  class FormatError < StandardError
40
40
  end
41
+
42
+ #
43
+ # Raised when an account's public key is invalid or unsupported.
44
+ #
45
+ class PublicKeyError < StandardError
46
+ end
41
47
  end
@@ -0,0 +1,5 @@
1
+ require_relative 'did'
2
+
3
+ unless defined?(::DID)
4
+ DID = DIDKit::DID
5
+ end
@@ -1,21 +1,22 @@
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.5.
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
@@ -35,11 +36,13 @@ module DIDKit
35
36
  end
36
37
 
37
38
  @last_page_cids = []
38
- @plc_service = service || PLC_SERVICE
39
+
40
+ @plc_service = service || DIDKit::DEFAULT_PLC_DIRECTORY
41
+ @plc_service = "https://#{@plc_service}" unless @plc_service.include?('://')
39
42
  end
40
43
 
41
44
  def get_export(args = {})
42
- url = URI("https://#{@plc_service}/export")
45
+ url = URI("#{@plc_service}/export")
43
46
  url.query = URI.encode_www_form(args)
44
47
 
45
48
  data = get_data(url, content_type: 'application/jsonlines')
@@ -47,7 +50,7 @@ module DIDKit
47
50
  end
48
51
 
49
52
  def fetch_audit_log(did)
50
- json = get_json("https://#{@plc_service}/#{did}/log/audit", :content_type => :json)
53
+ json = get_json("#{@plc_service}/#{did}/log/audit", :content_type => :json)
51
54
  json.map { |j| PLCOperation.new(j) }
52
55
  end
53
56
 
@@ -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,10 +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
- # @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
42
43
  attr_reader :services
43
44
 
44
45
 
@@ -92,6 +93,37 @@ module DIDKit
92
93
  @json['nullified'] == true
93
94
  end
94
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
126
+
95
127
 
96
128
  private
97
129
 
@@ -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
@@ -21,29 +21,55 @@ module DIDKit
21
21
  @services&.detect { |s| s.key == key && s.type == type }
22
22
  end
23
23
 
24
- # Returns the PDS service endpoint, if present.
24
+ # Returns the PDS service endpoint, if present and valid.
25
25
  #
26
26
  # If the DID has an `#atproto_pds` service declared in its `service` section,
27
27
  # returns the URL in its `serviceEndpoint` field. In other words, this is the URL
28
28
  # of the PDS assigned to a given user, which stores the user's account and repo.
29
29
  #
30
+ # Returns nil if the service record is present, but the URL is not a valid HTTP(S) URL.
31
+ # For PDS endpoint specifically, the URL should only contain the scheme, hostname, and
32
+ # optional port number, but no userinfo, path, query or other components. As an exception,
33
+ # a path in the form of a single trailing slash is also allowed.
34
+ #
30
35
  # @api public
31
36
  # @return [String, nil] PDS service endpoint URL
32
37
 
33
38
  def pds_endpoint
34
- @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
39
+ return @pds_endpoint if @pds_endpoint
40
+
41
+ endpoint = get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
42
+ return nil if endpoint.nil?
43
+
44
+ uri = URI(endpoint)
45
+
46
+ return nil if uri.scheme !~ /^https?$/ || uri.host.nil? || uri.host.empty?
47
+ return nil if uri.userinfo || uri.query || uri.fragment
48
+ return nil unless uri.path.empty? || uri.path == '/'
49
+
50
+ @pds_endpoint = endpoint
35
51
  end
36
52
 
37
- # Returns the labeler service endpoint, if present.
53
+ # Returns the labeler service endpoint, if present and valid.
38
54
  #
39
55
  # If the DID has an `#atproto_labeler` service declared in its `service` section,
40
56
  # returns the URL in its `serviceEndpoint` field.
41
57
  #
58
+ # Returns nil if the service record is present, but the URL is not an HTTP(S) URL.
59
+ #
42
60
  # @api public
43
61
  # @return [String, nil] labeler service endpoint URL
44
62
 
45
63
  def labeler_endpoint
46
- @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
64
+ return @labeler_endpoint if @labeler_endpoint
65
+
66
+ endpoint = get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
67
+ return nil if endpoint.nil?
68
+
69
+ uri = URI(endpoint)
70
+ return nil if uri.scheme !~ /^https?$/ || uri.host.nil? || uri.host.empty?
71
+
72
+ @labeler_endpoint = endpoint
47
73
  end
48
74
 
49
75
  # Returns the hostname of the PDS service, if present.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DIDKit
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/didkit.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "didkit/constants"
3
4
  require_relative "didkit/did"
4
5
  require_relative "didkit/document"
6
+ require_relative "didkit/global_did"
5
7
  require_relative "didkit/plc_importer"
6
8
  require_relative "didkit/plc_operation"
7
9
  require_relative "didkit/resolver"
@@ -9,5 +11,3 @@ require_relative "didkit/version"
9
11
 
10
12
  module DIDKit
11
13
  end
12
-
13
- DID = DIDKit::DID
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: didkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base58
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
12
26
  email:
13
27
  - jakub.suder@gmail.com
14
28
  executables: []
@@ -18,19 +32,22 @@ files:
18
32
  - CHANGELOG.md
19
33
  - LICENSE.txt
20
34
  - README.md
35
+ - SECURITY.md
21
36
  - lib/didkit.rb
22
37
  - lib/didkit/at_handles.rb
38
+ - lib/didkit/constants.rb
23
39
  - lib/didkit/did.rb
24
40
  - lib/didkit/document.rb
25
41
  - lib/didkit/errors.rb
42
+ - lib/didkit/global_did.rb
26
43
  - lib/didkit/plc_importer.rb
27
44
  - lib/didkit/plc_operation.rb
45
+ - lib/didkit/public_key.rb
28
46
  - lib/didkit/requests.rb
29
47
  - lib/didkit/resolver.rb
30
48
  - lib/didkit/service_record.rb
31
49
  - lib/didkit/services.rb
32
50
  - lib/didkit/version.rb
33
- - sig/didkit.rbs
34
51
  homepage: https://ruby.sdk.blue
35
52
  licenses:
36
53
  - Zlib
@@ -38,6 +55,7 @@ metadata:
38
55
  bug_tracker_uri: https://tangled.org/mackuba.eu/didkit/issues
39
56
  changelog_uri: https://tangled.org/mackuba.eu/didkit/blob/master/CHANGELOG.md
40
57
  source_code_uri: https://tangled.org/mackuba.eu/didkit
58
+ rubygems_mfa_required: 'true'
41
59
  rdoc_options: []
42
60
  require_paths:
43
61
  - lib
@@ -52,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
70
  - !ruby/object:Gem::Version
53
71
  version: '0'
54
72
  requirements: []
55
- rubygems_version: 4.0.15
73
+ rubygems_version: 4.0.18
56
74
  specification_version: 4
57
75
  summary: A library for handling Distributed ID (DID) identifiers used in Bluesky AT
58
76
  Protocol
data/sig/didkit.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Didkit
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end