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 +4 -4
- data/CHANGELOG.md +33 -2
- data/README.md +23 -2
- data/SECURITY.md +16 -0
- data/lib/didkit/at_handles.rb +13 -2
- data/lib/didkit/constants.rb +31 -0
- data/lib/didkit/did.rb +76 -22
- data/lib/didkit/document.rb +55 -9
- data/lib/didkit/errors.rb +6 -0
- data/lib/didkit/global_did.rb +5 -0
- data/lib/didkit/plc_importer.rb +9 -6
- data/lib/didkit/plc_operation.rb +34 -2
- data/lib/didkit/public_key.rb +116 -0
- data/lib/didkit/requests.rb +8 -11
- data/lib/didkit/resolver.rb +111 -43
- data/lib/didkit/services.rb +30 -4
- data/lib/didkit/version.rb +1 -1
- data/lib/didkit.rb +2 -2
- metadata +22 -4
- data/sig/didkit.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf656ddc5c0b21d616657dc8f7d310c1f5aba7b77828542a1871c05d177ecbc0
|
|
4
|
+
data.tar.gz: b23f42ec6c434fa1ba7867976310fac7cfa006c0d093ef105141c0cd5c8b8543
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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 :)
|
data/lib/didkit/at_handles.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
97
|
+
DIDKit.default_resolver.resolve_did(self)
|
|
91
98
|
end
|
|
92
99
|
|
|
93
|
-
# Returns the
|
|
100
|
+
# Returns the current primary handle assigned to the DID, if it verifies correctly.
|
|
94
101
|
#
|
|
95
|
-
# Looks up the
|
|
96
|
-
#
|
|
97
|
-
#
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
data/lib/didkit/document.rb
CHANGED
|
@@ -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)
|
|
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<
|
|
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
|
|
59
|
+
# Returns the current primary handle assigned to the DID, if it verifies correctly.
|
|
49
60
|
#
|
|
50
|
-
# Looks up the
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
data/lib/didkit/plc_importer.rb
CHANGED
|
@@ -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
|
|
13
|
-
# Things will change here in v. 0.
|
|
14
|
+
# NOTE: this class is pending a rewrite to handle updated plc.directory APIs.
|
|
15
|
+
# Things will change here in v. 0.6.
|
|
14
16
|
#
|
|
15
17
|
|
|
16
18
|
module DIDKit
|
|
17
19
|
class PLCImporter
|
|
18
|
-
PLC_SERVICE = 'plc.directory'
|
|
19
20
|
MAX_PAGE = 1000
|
|
20
21
|
|
|
21
22
|
include Requests
|
|
@@ -35,11 +36,13 @@ module DIDKit
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
@last_page_cids = []
|
|
38
|
-
|
|
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("
|
|
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("
|
|
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
|
|
data/lib/didkit/plc_operation.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'time'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'at_handles'
|
|
6
6
|
require_relative 'errors'
|
|
7
|
+
require_relative 'public_key'
|
|
7
8
|
require_relative 'service_record'
|
|
8
9
|
require_relative 'services'
|
|
9
10
|
|
|
@@ -35,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 [
|
|
39
|
+
# @return [Symbol] the operation type
|
|
39
40
|
attr_reader :type
|
|
40
41
|
|
|
41
|
-
# @return [Array<
|
|
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
|
data/lib/didkit/requests.rb
CHANGED
|
@@ -22,7 +22,6 @@ module DIDKit
|
|
|
22
22
|
timeout = options[:timeout] || 15
|
|
23
23
|
|
|
24
24
|
request_options = {
|
|
25
|
-
use_ssl: true,
|
|
26
25
|
open_timeout: timeout,
|
|
27
26
|
read_timeout: timeout
|
|
28
27
|
}
|
|
@@ -34,13 +33,18 @@ module DIDKit
|
|
|
34
33
|
loop do
|
|
35
34
|
visited_urls << url
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
request_options.update(use_ssl: (url.scheme == 'https'))
|
|
37
|
+
|
|
38
|
+
response = Net::HTTP.start(url.hostname, url.port, request_options) do |http|
|
|
38
39
|
request = Net::HTTP::Get.new(url)
|
|
39
40
|
http.request(request)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
if response.is_a?(Net::HTTPRedirection) && redirects < max_redirects && (location = response['Location'])
|
|
43
|
-
|
|
44
|
+
target = URI.join(url.to_s, location)
|
|
45
|
+
return response unless target.scheme == 'https' || (target.scheme == 'http' && url.scheme == 'http')
|
|
46
|
+
|
|
47
|
+
url = target
|
|
44
48
|
|
|
45
49
|
if visited_urls.include?(url)
|
|
46
50
|
return response
|
|
@@ -54,6 +58,7 @@ module DIDKit
|
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def get_data(url, options = {})
|
|
61
|
+
options = options.dup
|
|
57
62
|
content_type = options.delete(:content_type)
|
|
58
63
|
response = get_response(url, options)
|
|
59
64
|
|
|
@@ -84,13 +89,5 @@ module DIDKit
|
|
|
84
89
|
raise ArgumentError, "Invalid expected_type: #{expected_type.inspect}"
|
|
85
90
|
end
|
|
86
91
|
end
|
|
87
|
-
|
|
88
|
-
# backported from https://github.com/ruby/uri/pull/30/files for older Rubies
|
|
89
|
-
def uri_origin(uri)
|
|
90
|
-
uri = uri.is_a?(URI) ? uri : URI(uri)
|
|
91
|
-
authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
|
|
92
|
-
|
|
93
|
-
"#{uri.scheme}://#{authority}"
|
|
94
|
-
end
|
|
95
92
|
end
|
|
96
93
|
end
|
data/lib/didkit/resolver.rb
CHANGED
|
@@ -1,36 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'ipaddr'
|
|
3
4
|
require 'net/http'
|
|
4
5
|
require 'resolv'
|
|
6
|
+
require 'uri'
|
|
5
7
|
|
|
8
|
+
require_relative 'constants'
|
|
6
9
|
require_relative 'did'
|
|
7
10
|
require_relative 'document'
|
|
8
11
|
require_relative 'requests'
|
|
9
12
|
|
|
10
13
|
module DIDKit
|
|
11
14
|
|
|
15
|
+
class << self
|
|
16
|
+
# Default shared resolver used when calling {DID}/{Document} methods directly.
|
|
17
|
+
# @return [DIDKit::Resolver]
|
|
18
|
+
def default_resolver
|
|
19
|
+
@default_resolver ||= Resolver.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_writer :default_resolver
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
|
|
12
26
|
#
|
|
13
27
|
# A class which manages resolving of handles to DIDs and DIDs to DID documents.
|
|
14
28
|
#
|
|
15
29
|
|
|
16
30
|
class Resolver
|
|
17
|
-
# These TLDs are not allowed in ATProto handles, so the resolver returns nil for them
|
|
18
|
-
# without trying to look them up.
|
|
19
|
-
RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
|
|
20
|
-
|
|
21
31
|
include Requests
|
|
22
32
|
|
|
23
|
-
# @return [String, Array<String
|
|
24
|
-
|
|
33
|
+
# @return [String, Array<String>, nil] custom DNS nameserver(s) to use for DNS TXT lookups
|
|
34
|
+
attr_reader :nameserver
|
|
25
35
|
|
|
26
36
|
# @param options [Hash] resolver options
|
|
27
37
|
# @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs)
|
|
38
|
+
# @option options [String] :plc_directory URL or host[:port] of an alternate PLC directory,
|
|
39
|
+
# without a path; defaults to HTTPS (HTTP allowed for localhost/loopback IPs only)
|
|
28
40
|
# @option options [Integer] :timeout request timeout in seconds (default: 15)
|
|
29
41
|
# @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
|
|
30
|
-
|
|
42
|
+
# @raise [ArgumentError] if the PLC directory address is invalid
|
|
43
|
+
#
|
|
31
44
|
def initialize(options = {})
|
|
32
45
|
@nameserver = options[:nameserver]
|
|
33
|
-
@
|
|
46
|
+
@plc_service = options.key?(:plc_directory) ? parse_plc_service(options[:plc_directory]) : DEFAULT_PLC_DIRECTORY
|
|
47
|
+
@request_options = options.slice(:timeout, :max_redirects).freeze
|
|
34
48
|
end
|
|
35
49
|
|
|
36
50
|
# Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
|
|
@@ -41,46 +55,56 @@ module DIDKit
|
|
|
41
55
|
# check which one it is.
|
|
42
56
|
#
|
|
43
57
|
# @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
|
|
58
|
+
# @param verify [Boolean] require the handle to be assigned to the DID in the DID document (default: true)
|
|
44
59
|
# @return [DID, nil] resolved DID if found, nil otherwise
|
|
60
|
+
# @raise [APIError] if verification is enabled and the DID document request is unsuccessful
|
|
61
|
+
# @raise [FormatError] if verification is enabled and the DID document has invalid data
|
|
45
62
|
|
|
46
|
-
def resolve_handle(handle)
|
|
47
|
-
if handle.is_a?(DID) || handle
|
|
48
|
-
|
|
63
|
+
def resolve_handle(handle, verify: true)
|
|
64
|
+
if !(handle.is_a?(DID) || handle.is_a?(String))
|
|
65
|
+
raise ArgumentError, "handle argument should be a String or a DID object"
|
|
49
66
|
end
|
|
50
67
|
|
|
51
|
-
|
|
68
|
+
if handle.is_a?(DID) || handle =~ DIDKit::GENERIC_DID_REGEXP
|
|
69
|
+
return DID.new(handle)
|
|
70
|
+
end
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
domain = handle.gsub(/^@/, '').downcase(:ascii)
|
|
73
|
+
return nil unless valid_handle_identifier?(domain)
|
|
54
74
|
|
|
55
75
|
if dns_did = resolve_handle_by_dns(domain)
|
|
56
|
-
DID.new(dns_did, :dns)
|
|
76
|
+
referenced_did = DID.new(dns_did, :dns)
|
|
57
77
|
elsif http_did = resolve_handle_by_well_known(domain)
|
|
58
|
-
DID.new(http_did, :http)
|
|
78
|
+
referenced_did = DID.new(http_did, :http)
|
|
59
79
|
else
|
|
60
|
-
nil
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if verify
|
|
84
|
+
document = self.resolve_did(referenced_did)
|
|
85
|
+
return nil unless document.handles.first&.downcase == domain
|
|
61
86
|
end
|
|
87
|
+
|
|
88
|
+
referenced_did
|
|
62
89
|
end
|
|
63
90
|
|
|
64
91
|
# Tries to resolve a handle into DID using the DNS TXT method.
|
|
65
92
|
#
|
|
66
93
|
# Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is
|
|
67
|
-
# a correct DID string.
|
|
94
|
+
# a correct DID string. Records are rejected if there exists more than one valid entry and they
|
|
95
|
+
# are referencing two or more different DIDs.
|
|
68
96
|
#
|
|
69
97
|
# @param domain [String] a domain handle to look up
|
|
70
|
-
# @return [String, nil] resolved DID if found, nil otherwise
|
|
98
|
+
# @return [String, nil] resolved DID if one valid record is found, nil otherwise
|
|
71
99
|
|
|
72
100
|
def resolve_handle_by_dns(domain)
|
|
73
101
|
dns_records = Resolv::DNS.open(resolv_options) do |d|
|
|
74
102
|
d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
|
|
75
103
|
end
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
if string = record.strings.first
|
|
79
|
-
return parse_did_from_dns(string)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
105
|
+
dids = dns_records.map { |record| parse_did_from_dns(record.data) }.compact.uniq
|
|
82
106
|
|
|
83
|
-
nil
|
|
107
|
+
dids.length == 1 ? dids.first : nil
|
|
84
108
|
end
|
|
85
109
|
|
|
86
110
|
# Tries to resolve a handle into DID using the HTTP .well-known method.
|
|
@@ -100,14 +124,15 @@ module DIDKit
|
|
|
100
124
|
end
|
|
101
125
|
|
|
102
126
|
nil
|
|
103
|
-
rescue StandardError
|
|
127
|
+
rescue StandardError
|
|
104
128
|
nil
|
|
105
129
|
end
|
|
106
130
|
|
|
107
131
|
# Resolve a DID to a DID document.
|
|
108
132
|
#
|
|
109
133
|
# Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
|
|
110
|
-
# [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain
|
|
134
|
+
# [plc.directory](https://plc.directory) or its mirror for did:plc DIDs, or the did:web's domain
|
|
135
|
+
# for did:web DIDs.
|
|
111
136
|
#
|
|
112
137
|
# @param did [String, DID] DID string or object
|
|
113
138
|
# @return [Document] resolved DID document
|
|
@@ -119,11 +144,11 @@ module DIDKit
|
|
|
119
144
|
did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
|
|
120
145
|
end
|
|
121
146
|
|
|
122
|
-
# Returns the
|
|
147
|
+
# Returns the current primary handle assigned to the given DID, if it verifies correctly.
|
|
123
148
|
#
|
|
124
|
-
# Looks up the
|
|
125
|
-
#
|
|
126
|
-
#
|
|
149
|
+
# Looks up the first syntactically valid handle in the DID document and checks if it is assigned
|
|
150
|
+
# correctly to this DID using DNS TXT or .well-known. Any later handles are ignored, even if the
|
|
151
|
+
# first handle fails verification. Returns nil if the primary handle cannot be verified.
|
|
127
152
|
#
|
|
128
153
|
# @param subject [String, DID, Document] a DID or its DID document
|
|
129
154
|
# @return [String, nil] verified handle domain, if found
|
|
@@ -131,44 +156,87 @@ module DIDKit
|
|
|
131
156
|
def get_verified_handle(subject)
|
|
132
157
|
document = subject.is_a?(Document) ? subject : resolve_did(subject)
|
|
133
158
|
|
|
134
|
-
|
|
135
|
-
end
|
|
159
|
+
primary_handle = document.handles.first
|
|
136
160
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# @param did [DID, String] DID to verify the handles against
|
|
140
|
-
# @param handles [Array<String>] handles to check
|
|
141
|
-
# @return [String, nil] a verified handle, if found
|
|
161
|
+
return nil if primary_handle.nil?
|
|
162
|
+
return nil unless resolve_handle(primary_handle, verify: false) == document.did.to_s
|
|
142
163
|
|
|
143
|
-
|
|
144
|
-
handles.detect { |h| resolve_handle(h) == did.to_s }
|
|
164
|
+
primary_handle
|
|
145
165
|
end
|
|
146
166
|
|
|
147
167
|
|
|
148
168
|
private
|
|
149
169
|
|
|
170
|
+
def valid_handle_identifier?(handle)
|
|
171
|
+
return false unless handle =~ DIDKit::HANDLE_REGEXP && handle.length <= DIDKit::MAX_HANDLE_LENGTH
|
|
172
|
+
return false if DIDKit::RESERVED_DOMAINS.include?(handle.split('.').last)
|
|
173
|
+
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
150
177
|
def resolv_options
|
|
151
178
|
options = Resolv::DNS::Config.default_config_hash.dup
|
|
152
179
|
options[:nameserver] = nameserver if nameserver
|
|
153
180
|
options
|
|
154
181
|
end
|
|
155
182
|
|
|
183
|
+
def parse_plc_service(url)
|
|
184
|
+
raise ArgumentError, 'PLC directory URL must be a string' unless url.is_a?(String)
|
|
185
|
+
|
|
186
|
+
url = "https://#{url}" unless url.include?('://')
|
|
187
|
+
uri = URI.parse(url.chomp('/'))
|
|
188
|
+
|
|
189
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty? && uri.path.empty?
|
|
190
|
+
raise ArgumentError, 'PLC directory must be a hostname or a HTTP(S) URL without a path'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
unless !uri.userinfo && !uri.query && !uri.fragment
|
|
194
|
+
raise ArgumentError, 'PLC directory URL must not include user credentials, query, or fragment part'
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if uri.scheme == 'http' && !loopback_host?(uri.hostname)
|
|
198
|
+
raise ArgumentError, 'http:// URLs are allowed only with localhost or a loopback IP'
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
uri.to_s
|
|
202
|
+
rescue URI::InvalidURIError
|
|
203
|
+
raise ArgumentError, 'Invalid PLC directory URL'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def loopback_host?(host)
|
|
207
|
+
host.downcase(:ascii) == 'localhost' || IPAddr.new(host).loopback?
|
|
208
|
+
rescue IPAddr::InvalidAddressError
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def valid_did?(did)
|
|
213
|
+
DID.new(did)
|
|
214
|
+
true
|
|
215
|
+
rescue DIDError
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
156
219
|
def parse_did_from_dns(txt)
|
|
157
|
-
|
|
220
|
+
return nil unless txt.start_with?('did=')
|
|
221
|
+
|
|
222
|
+
did = txt.delete_prefix('did=')
|
|
223
|
+
|
|
224
|
+
valid_did?(did) ? did : nil
|
|
158
225
|
end
|
|
159
226
|
|
|
160
227
|
def parse_did_from_well_known(text)
|
|
161
228
|
text = text.strip
|
|
162
|
-
text.lines.length == 1 && text
|
|
229
|
+
text.lines.length == 1 && valid_did?(text) ? text : nil
|
|
163
230
|
end
|
|
164
231
|
|
|
165
232
|
def resolve_did_plc(did)
|
|
166
|
-
|
|
233
|
+
options = @request_options.merge(content_type: /^application\/did(\+ld)?\+json(;.+)?$/)
|
|
234
|
+
json = get_json("#{@plc_service}/#{did}", options)
|
|
167
235
|
Document.new(did, json)
|
|
168
236
|
end
|
|
169
237
|
|
|
170
238
|
def resolve_did_web(did)
|
|
171
|
-
json = get_json("
|
|
239
|
+
json = get_json("#{did.did_web_host}/.well-known/did.json", @request_options)
|
|
172
240
|
Document.new(did, json)
|
|
173
241
|
end
|
|
174
242
|
end
|
data/lib/didkit/services.rb
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
data/lib/didkit/version.rb
CHANGED
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
|
+
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.
|
|
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