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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3fae54f7c1e125655363ac9b8585f5622b87afb371b170fbe8d4d6d25091fd38
4
- data.tar.gz: 9de4a4d69ef03399b9bed045b1647226fc92c547b3f017bb4127fa2cb674f9d5
3
+ metadata.gz: cf656ddc5c0b21d616657dc8f7d310c1f5aba7b77828542a1871c05d177ecbc0
4
+ data.tar.gz: b23f42ec6c434fa1ba7867976310fac7cfa006c0d093ef105141c0cd5c8b8543
5
5
  SHA512:
6
- metadata.gz: a52a90e39e7fea15530a9a4e53e006fdbfde90cf919cad0f46552e3740e7a881ace83d1eaba8ca09ffd13fc57f60c20b018ce5779b4b8adf1d8a478d68fc58cf
7
- data.tar.gz: fbfb3f01d8b56bc6dfe2f975995c6dc4da75901c43d177e85d0834b920b55036c53ccfc59f1b16e2f4f8f9e0c30b21f719fbecd2a1d28d893556a81f137f52f6
6
+ metadata.gz: c0227bd5bad67bba3086ac4cc3f766d8976cfd0f4d6a882039676b9792a9c1bcb8a9dd69015918b7c5100613d0689a3460fa0350a25c0ab63a1c54b7e3df7c22
7
+ data.tar.gz: '017428fc8d8cae144b57029f1547615788ab9fb097ab45403803e94e61954c72511cdf68412c053da40437ba6bdd0f3be86990146dcd142389167ebd4febd84e'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
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
+
34
+ ## [0.4.0] - 2026-07-02
35
+
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
37
+ - added `PLCOperation#nullified?`
38
+ - added support for parsing `handle` and `service` fields from legacy (2022-23 era) `:create` PLC ops in `PLCOperation`
39
+ - `PLCImporter` can be configured with a custom hostname of a PLC mirror to use instead of [plc.directory](https://plc.directory)
40
+ - `PLCImporter` will no longer update cursor to local request time if an empty page is received, but will instead leave the cursor as is – local time might have some offset vs. server time if the clock isn't set right, and this could lead to skipped operations
41
+ - in `DID#account_status` / `account_active?` / `account_exists?`, accept `getRepoStatus` endpoint returning responses with status 404 instead of 400 (since some PDS implementations do it this way)
42
+ - tweaked parsing of services in `Document` and `PLCOperation`:
43
+ 1. `Document` raises format errors if the fields are missing or not of expected type (since this shouldn't happen)
44
+ 2. `Document` & `PLCOperation` silently ignore the service if its endpoint is not a valid URI (since this does happen)
45
+ 3. Removed `ignore_errors` / `error_handler` from `PLCImporter`, since because of 2) no expected errors are emitted outside `PLCOperation`
46
+
1
47
  ## [0.3.3] - 2026-02-22
2
48
 
3
49
  - fixed old `FormatError` classes mentioned in `PLCImporter`
@@ -38,8 +84,6 @@ Also:
38
84
  - `PLCImporter` now removes duplicate operations at the edge of pages returned from the `/export` API
39
85
  - rewritten some networking code – all classes now use `Net::HTTP` with consistent options instead of `open-uri`
40
86
 
41
- 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.
42
-
43
87
  ## [0.2.3] - 2024-07-02
44
88
 
45
89
  - added a `DID#get_audit_log` method that fetches the PLC audit log for a DID
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # DIDKit 🪪
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/didkit.svg?icon=si%3Arubygems&icon_color=%23ff6251)](https://rubygems.org/gems/didkit) [![YARD Docs](http://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/didkit)
4
+
3
5
  A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol.
4
6
 
5
7
  > [!NOTE]
@@ -13,7 +15,7 @@ Accounts on Bluesky use identifiers like [did:plc:oio4hkxaop4ao4wz2pp3f4cr](http
13
15
 
14
16
  ## Installation
15
17
 
16
- 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/)).
17
19
 
18
20
  To install the gem, run in the command line:
19
21
 
@@ -21,7 +23,7 @@ To install the gem, run in the command line:
21
23
 
22
24
  Or add this to your app's `Gemfile`:
23
25
 
24
- gem 'didkit', '~> 0.3'
26
+ gem 'didkit', '~> 0.5'
25
27
 
26
28
 
27
29
  ## Usage
@@ -40,6 +42,8 @@ This returns a `DID` object, which tells you:
40
42
  - the DID type (`#type`, `:plc` or `:web`)
41
43
  - if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
42
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
+
43
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`:
44
48
 
45
49
  ```rb
@@ -86,9 +90,10 @@ You can customize some things about the DID/handle lookups by using the `DIDKit:
86
90
 
87
91
  Currently available options include:
88
92
 
89
- - `:nameserver` - override the nameserver used for DNS lookups, e.g. to use Google's or CloudFlare's DNS
90
- - `:timeout` - change the connection/response timeout for HTTP requests (default: 15 s)
91
- - `:max_redirects` - change allowed maximum number of redirects (default: 5)
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`
95
+ - `:timeout` – change the connection/response timeout for HTTP requests (default: 15 s)
96
+ - `:max_redirects` – change allowed maximum number of redirects (default: 5)
92
97
 
93
98
  Example:
94
99
 
@@ -106,6 +111,31 @@ resolver.get_verified_handle(did)
106
111
  # => 'nytimes.com'
107
112
  ```
108
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
+
132
+ ## Other resources
133
+
134
+ - [YARD API documentation](https://rubydoc.info/gems/didkit) at rubydoc.info
135
+ - [ruby.sdk.blue](https://ruby.sdk.blue)
136
+ - [Example scripts](https://ruby.sdk.blue/examples/)
137
+
138
+
109
139
  ## Credits
110
140
 
111
141
  Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
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,22 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'constants'
3
4
  require_relative 'errors'
4
5
 
5
6
  module DIDKit
6
7
 
7
8
  #
8
- # @private
9
+ # @api private
9
10
  #
10
11
 
11
12
  module AtHandles
12
13
 
14
+ # Returns a list of ATProto handles assigned to this DID in its DID document.
15
+ #
16
+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
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.
22
+ #
23
+ # @api public
24
+ # @return [Array<String>]
25
+
26
+ attr_reader :handles
27
+
28
+ # Returns a list of all identifiers assigned to this DID in its DID document through the
29
+ # `alsoKnownAs` field. This includes ATProto handles (in the format `at://example.com`) and
30
+ # potentially other URIs like `http` URLs (e.g. for Bridgy accounts), and even (technically
31
+ # invalid) non-URI strings. Use {#handles} to get just the ATProto handles.
32
+ #
33
+ # @api public
34
+ # @return [Array<String>]
35
+
36
+ attr_reader :also_known_as
37
+
13
38
  private
14
39
 
15
40
  def parse_also_known_as(aka)
16
41
  raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.is_a?(Array)
17
42
  raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.all? { |x| x.is_a?(String) }
18
43
 
19
- aka.select { |x| x =~ %r(\Aat://[^/]+\z) }.map { |x| x.gsub('at://', '') }
44
+ @also_known_as = aka
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 }
20
52
  end
21
53
  end
22
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)
@@ -148,17 +173,19 @@ module DIDKit
148
173
  json = JSON.parse(response.body)
149
174
 
150
175
  if json['active'] == true
151
- :active
176
+ return :active
152
177
  elsif json['active'] == false && json['status'].is_a?(String) && json['status'].length <= 100
153
- json['status'].to_sym
154
- else
155
- raise APIError.new(response)
178
+ return json['status'].to_sym
179
+ end
180
+ elsif (status == 400 || status == 404) && is_json
181
+ json = JSON.parse(response.body)
182
+
183
+ if json['error'] == 'RepoNotFound'
184
+ return nil
156
185
  end
157
- elsif status == 400 && is_json && JSON.parse(response.body)['error'] == 'RepoNotFound'
158
- nil
159
- else
160
- raise APIError.new(response)
161
186
  end
187
+
188
+ raise APIError.new(response)
162
189
  end
163
190
 
164
191
  # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint.
@@ -195,5 +222,34 @@ module DIDKit
195
222
  false
196
223
  end
197
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
198
254
  end
199
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,30 +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
- # Returns a list of handles assigned to this DID in its DID document.
28
- #
29
- # Note: the handles aren't guaranteed to be verified (validated in the other direction).
30
- # Use {#get_verified_handle} to find a handle that is correctly verified.
31
- #
32
- # @return [Array<String>]
33
- attr_reader :handles
34
-
35
- # @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
36
36
  attr_reader :services
37
37
 
38
38
  # Creates a DID document object.
@@ -42,6 +42,9 @@ module DIDKit
42
42
  # @raise [FormatError] when required fields are missing or invalid.
43
43
 
44
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
+
45
48
  raise FormatError, "Missing id field" if json['id'].nil?
46
49
  raise FormatError, "Invalid id field" unless json['id'].is_a?(String)
47
50
  raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s
@@ -49,20 +52,46 @@ module DIDKit
49
52
  @did = did
50
53
  @json = json
51
54
 
52
- @services = parse_services(json['service'] || [])
53
- @handles = parse_also_known_as(json['alsoKnownAs'] || [])
55
+ parse_services(json['service'] || [])
56
+ parse_also_known_as(json['alsoKnownAs'] || [])
54
57
  end
55
58
 
56
- # Returns the first verified handle assigned to the DID.
59
+ # Returns the current primary handle assigned to the DID, if it verifies correctly.
57
60
  #
58
- # Looks up the domain handles assigned to this DID in the DID document, checks if they are
59
- # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
60
- # 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.
61
64
  #
62
65
  # @return [String, nil] verified handle domain, if found
63
66
 
64
67
  def get_verified_handle
65
- 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
66
95
  end
67
96
 
68
97
 
@@ -71,17 +100,36 @@ module DIDKit
71
100
  def parse_services(service_data)
72
101
  raise FormatError, "Invalid service data" unless service_data.is_a?(Array) && service_data.all? { |x| x.is_a?(Hash) }
73
102
 
74
- services = []
103
+ @services = []
75
104
 
76
105
  service_data.each do |x|
77
106
  id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
78
107
 
79
- if id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
80
- services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
108
+ raise FormatError, "Missing service id" unless id
109
+ raise FormatError, "Invalid service id: #{id.inspect}" unless id.is_a?(String)
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(/^#/, '')
81
119
  end
82
- end
83
120
 
84
- services
121
+ raise FormatError, "Missing service type" unless type
122
+ raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
123
+
124
+ raise FormatError, "Missing service endpoint" unless endpoint
125
+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
126
+
127
+ begin
128
+ @services << ServiceRecord.new(id, type, endpoint)
129
+ rescue FormatError => e
130
+ # ignore services with invalid URIs
131
+ end
132
+ end
85
133
  end
86
134
  end
87
135
  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