didkit 0.0.3 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +67 -5
- data/lib/didkit/did.rb +15 -57
- data/lib/didkit/document.rb +7 -1
- data/lib/didkit/plc_importer.rb +45 -0
- data/lib/didkit/plc_operation.rb +56 -0
- data/lib/didkit/resolver.rb +115 -0
- data/lib/didkit/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7744ae1dc9ba4ca678d641139ffa94931130ee7f91fa93d3ee89e3d6e196ba8d
|
4
|
+
data.tar.gz: 4111e824027b646aa8b3a21fb3032dfdba26019b6fecaf3f40f2120dfa417e52
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ed96e325c2d0e8152b1db1f6a46e34afdbec049e0442f2298a89a2bbd07afb96e3ca166fca40adf1ff90d0da02997b825addd20163eb858c6074771d9ea229ce
|
7
|
+
data.tar.gz: 9237183a427ad4f5380504334abcfb16a965b0e95ce39693e9aa8d58d24c6acc6c00d7150bd86be18332e02da3cbd379eb516ccb60716a3b09bf11efc155c26c
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,18 @@
|
|
1
|
+
## [0.1.0] - 2024-03-12
|
2
|
+
|
3
|
+
- rejecting handles from disallowed domains like `.arpa` or `.test`
|
4
|
+
- validating handles with the `.well-known` file having a trailing newline
|
5
|
+
- validating handles with `.well-known` address returning a redirect
|
6
|
+
- added `#pick_valid_handle` helper
|
7
|
+
- allow overriding the nameserver for `Resolv::DNS`
|
8
|
+
- other bug fixes
|
9
|
+
|
10
|
+
## [0.0.4] - 2024-03-07
|
11
|
+
|
12
|
+
- extracted resolving code from `DID` to a new `Resolver` class (`DID` has helper methods to call the resolver)
|
13
|
+
- added `Resolver#get_validated_handle` method to validate handles from the `Document` (+ helpers in `DID` in `Document`)
|
14
|
+
- added timeout to `#resolve_handle_by_well_known`
|
15
|
+
|
1
16
|
## [0.0.3] - 2024-03-06
|
2
17
|
|
3
18
|
- added `Document#handles` with handle info extracted from `alsoKnownAs` field
|
data/README.md
CHANGED
@@ -1,13 +1,11 @@
|
|
1
|
-
#
|
1
|
+
# DIDKit
|
2
2
|
|
3
3
|
A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol
|
4
4
|
|
5
5
|
|
6
6
|
## What does it do
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
See the [did.rb](https://github.com/mackuba/didkit/blob/master/lib/didkit/did.rb) file for now.
|
8
|
+
Accounts on Bluesky use identifiers like [did:plc:oio4hkxaop4ao4wz2pp3f4cr](https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr) as unique IDs, and they also have assigned human-readable handles like [@mackuba.eu](https://bsky.app/profile/mackuba.eu), which are verified either through a DNS TXT entry or a `/.well-known/atproto-did` file. This library allows you to look up any account's assigned handle using a DID string or vice versa, load the account's DID JSON document that specifies the handles and the PDS server hosting user's repo, and check if the assigned handle verifies correctly.
|
11
9
|
|
12
10
|
|
13
11
|
## Installation
|
@@ -15,8 +13,72 @@ See the [did.rb](https://github.com/mackuba/didkit/blob/master/lib/didkit/did.rb
|
|
15
13
|
gem install didkit
|
16
14
|
|
17
15
|
|
16
|
+
## Usage
|
17
|
+
|
18
|
+
Use the `DIDKit::Resolver` class to look up DIDs and handles.
|
19
|
+
|
20
|
+
To look up a handle:
|
21
|
+
|
22
|
+
```rb
|
23
|
+
resolver = DIDKit::Resolver.new
|
24
|
+
resolver.resolve_handle('nytimes.com')
|
25
|
+
# => #<DIDKit::DID:0x00000001035956b0 @did="did:plc:eclio37ymobqex2ncko63h4r", @type=:plc, @resolved_by=:dns>
|
26
|
+
```
|
27
|
+
|
28
|
+
This returns an object of `DIDKit::DID` class (aliased as just `DID`), which tells you:
|
29
|
+
|
30
|
+
- the DID as a string (`#to_s` or `#did`)
|
31
|
+
- the DID type (`#type`, `:plc` or `:web`)
|
32
|
+
- if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
|
33
|
+
|
34
|
+
To go in the other direction – to find an assigned and verified handle given a DID – use `get_validated_handle` (pass DID as a string or an object):
|
35
|
+
|
36
|
+
```rb
|
37
|
+
resolver.get_validated_handle('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
|
38
|
+
# => "atproto.com"
|
39
|
+
```
|
40
|
+
|
41
|
+
You can also load the DID document using `resolve_did`:
|
42
|
+
|
43
|
+
```rb
|
44
|
+
doc = resolver.resolve_did('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
|
45
|
+
# => #<DIDKit::Document:0x0000000105d751f8 @did=#<DIDKit::DID:...>, @json={...}>
|
46
|
+
|
47
|
+
doc.handles
|
48
|
+
# => ["pfrazee.com"]
|
49
|
+
|
50
|
+
doc.pds_endpoint
|
51
|
+
# => "https://morel.us-east.host.bsky.network"
|
52
|
+
```
|
53
|
+
|
54
|
+
There are also some helper methods in the `DID` class that create a `Resolver` for you to save you some typing:
|
55
|
+
|
56
|
+
```rb
|
57
|
+
did = DID.resolve_handle('jay.bsky.team')
|
58
|
+
# => #<DIDKit::DID:0x000000010615ed28 @did="did:plc:oky5czdrnfjpqslsw2a5iclo", @type=:plc, @resolved_by=:dns>
|
59
|
+
|
60
|
+
did.to_s
|
61
|
+
# => "did:plc:oky5czdrnfjpqslsw2a5iclo"
|
62
|
+
|
63
|
+
did.get_document
|
64
|
+
# => #<DIDKit::Document:0x00000001066d4898 @did=#<DIDKit::DID:...>, @json={...}>
|
65
|
+
|
66
|
+
did.get_validated_handle
|
67
|
+
# => "jay.bsky.team"
|
68
|
+
```
|
69
|
+
|
70
|
+
|
71
|
+
### Configuration
|
72
|
+
|
73
|
+
You can override the nameserver used for DNS lookups by setting the `nameserver` property in `Resolver`, e.g. to use Google's or CloudFlare's global DNS:
|
74
|
+
|
75
|
+
```
|
76
|
+
resolver.nameserver = '8.8.8.8'
|
77
|
+
```
|
78
|
+
|
79
|
+
|
18
80
|
## Credits
|
19
81
|
|
20
|
-
Copyright ©
|
82
|
+
Copyright © 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
|
21
83
|
|
22
84
|
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
data/lib/didkit/did.rb
CHANGED
@@ -1,54 +1,10 @@
|
|
1
|
-
require 'json'
|
2
|
-
require 'net/http'
|
3
|
-
require 'open-uri'
|
4
|
-
require 'resolv'
|
5
|
-
|
6
|
-
require_relative 'document'
|
7
1
|
require_relative 'errors'
|
2
|
+
require_relative 'resolver'
|
8
3
|
|
9
4
|
module DIDKit
|
10
5
|
class DID
|
11
6
|
def self.resolve_handle(handle)
|
12
|
-
|
13
|
-
|
14
|
-
if dns_did = resolve_handle_by_dns(domain)
|
15
|
-
DID.new(dns_did, :dns)
|
16
|
-
elsif http_did = resolve_handle_by_well_known(domain)
|
17
|
-
DID.new(http_did, :http)
|
18
|
-
else
|
19
|
-
nil
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
def self.resolve_handle_by_dns(domain)
|
24
|
-
dns_records = Resolv::DNS.open { |d| d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) }
|
25
|
-
|
26
|
-
if record = dns_records.first
|
27
|
-
if string = record.strings.first
|
28
|
-
if string =~ /^did\=(did\:\w+\:.*)$/
|
29
|
-
return $1
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
nil
|
35
|
-
end
|
36
|
-
|
37
|
-
def self.resolve_handle_by_well_known(domain)
|
38
|
-
url = URI("https://#{domain}/.well-known/atproto-did")
|
39
|
-
response = Net::HTTP.get_response(url)
|
40
|
-
|
41
|
-
if response.is_a?(Net::HTTPSuccess)
|
42
|
-
if text = response.body
|
43
|
-
if text.lines.length == 1 && text.start_with?('did:')
|
44
|
-
return text
|
45
|
-
end
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
nil
|
50
|
-
rescue StandardError => e
|
51
|
-
nil
|
7
|
+
Resolver.new.resolve_handle(handle)
|
52
8
|
end
|
53
9
|
|
54
10
|
attr_reader :type, :did, :resolved_by
|
@@ -71,23 +27,25 @@ module DIDKit
|
|
71
27
|
alias to_s did
|
72
28
|
|
73
29
|
def get_document
|
74
|
-
|
30
|
+
Resolver.new.resolve_did(self)
|
75
31
|
end
|
76
32
|
|
77
|
-
def
|
78
|
-
|
33
|
+
def get_validated_handle
|
34
|
+
Resolver.new.get_validated_handle(self)
|
79
35
|
end
|
80
36
|
|
81
|
-
def
|
82
|
-
|
83
|
-
json = JSON.parse(URI.open(url).read)
|
84
|
-
Document.new(self, json)
|
37
|
+
def web_domain
|
38
|
+
did.gsub(/^did\:web\:/, '') if type == :web
|
85
39
|
end
|
86
40
|
|
87
|
-
def
|
88
|
-
|
89
|
-
|
90
|
-
|
41
|
+
def ==(other)
|
42
|
+
if other.is_a?(String)
|
43
|
+
self.did == other
|
44
|
+
elsif other.is_a?(DID)
|
45
|
+
self.did == other.did
|
46
|
+
else
|
47
|
+
false
|
48
|
+
end
|
91
49
|
end
|
92
50
|
end
|
93
51
|
end
|
data/lib/didkit/document.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative 'resolver'
|
2
|
+
|
1
3
|
module DIDKit
|
2
4
|
class Document
|
3
5
|
class FormatError < StandardError
|
@@ -8,7 +10,7 @@ module DIDKit
|
|
8
10
|
def initialize(did, json)
|
9
11
|
raise FormatError, "Missing id field" if json['id'].nil?
|
10
12
|
raise FormatError, "Invalid id field" unless json['id'].is_a?(String)
|
11
|
-
raise FormatError, "
|
13
|
+
raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s
|
12
14
|
|
13
15
|
@did = did
|
14
16
|
@json = json
|
@@ -37,5 +39,9 @@ module DIDKit
|
|
37
39
|
@handles = []
|
38
40
|
end
|
39
41
|
end
|
42
|
+
|
43
|
+
def get_validated_handle
|
44
|
+
Resolver.new.pick_valid_handle(did, handles)
|
45
|
+
end
|
40
46
|
end
|
41
47
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'net/http'
|
3
|
+
require 'open-uri'
|
4
|
+
require 'time'
|
5
|
+
|
6
|
+
require_relative 'plc_operation'
|
7
|
+
|
8
|
+
module DIDKit
|
9
|
+
class PLCImporter
|
10
|
+
PLC_SERVICE = 'plc.directory'
|
11
|
+
|
12
|
+
attr_accessor :ignore_errors
|
13
|
+
|
14
|
+
def initialize(last_date = Time.now)
|
15
|
+
@last_date = last_date
|
16
|
+
@ignore_errors = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def fetch
|
20
|
+
url = URI("https://#{PLC_SERVICE}/export")
|
21
|
+
url.query = URI.encode_www_form(:after => @last_date.utc.iso8601(6)) if @last_date
|
22
|
+
request_time = Time.now
|
23
|
+
|
24
|
+
data = URI.open(url).read
|
25
|
+
rows = data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) }
|
26
|
+
|
27
|
+
operations = rows.filter_map do |json|
|
28
|
+
begin
|
29
|
+
PLCOperation.new(json)
|
30
|
+
rescue PLCOperation::FormatError => e
|
31
|
+
ignore_errors ? nil : raise
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
block.call(operations)
|
36
|
+
|
37
|
+
if rows.length == 1000
|
38
|
+
@last_date = operations.last.created_at || request_time
|
39
|
+
run_update
|
40
|
+
else
|
41
|
+
@last_date = request_time
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module DIDKit
|
4
|
+
class PLCOperation
|
5
|
+
class FormatError < StandardError
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :did, :created_at, :type, :pds_endpoint, :handles
|
9
|
+
|
10
|
+
def initialize(json)
|
11
|
+
@did = json['did']
|
12
|
+
raise FormatError, "Missing DID" if @did.nil?
|
13
|
+
raise FormatError, "Invalid DID" unless @did.is_a?(String) && @did.start_with?('did:')
|
14
|
+
|
15
|
+
timestamp = json['createdAt']
|
16
|
+
raise FormatError, "Missing createdAt" if timestamp.nil?
|
17
|
+
raise FormatError, "Invalid createdAt" unless timestamp.is_a?(String)
|
18
|
+
|
19
|
+
@created_at = Time.parse(timestamp)
|
20
|
+
|
21
|
+
operation = json['operation']
|
22
|
+
raise FormatError, "Missing operation key" if operation.nil?
|
23
|
+
raise FormatError, "Invalid operation data" unless operation.is_a?(Hash)
|
24
|
+
|
25
|
+
type = operation['type']
|
26
|
+
raise FormatError, "Missing type" if type.nil?
|
27
|
+
|
28
|
+
@type = type.to_sym
|
29
|
+
return unless @type == :plc_operation
|
30
|
+
|
31
|
+
services = operation['services']
|
32
|
+
raise FormatError, "Missing services key" if services.nil?
|
33
|
+
raise FormatError, "Invalid services data" unless services.is_a?(Hash)
|
34
|
+
|
35
|
+
if pds = services['atproto_pds']
|
36
|
+
raise FormatError, "Invalid PDS data" unless pds.is_a?(Hash)
|
37
|
+
raise FormatError, "Missing PDS type" unless pds['type']
|
38
|
+
raise FormatError, "Invalid PDS type" unless pds['type'] == 'AtprotoPersonalDataServer'
|
39
|
+
raise FormatError, "Missing PDS endpoint" unless pds['endpoint']
|
40
|
+
raise FormatError, "Invalid PDS endpoint" unless pds['endpoint'].is_a?(String) && pds['endpoint'] =~ %r(://)
|
41
|
+
|
42
|
+
@pds_endpoint = pds['endpoint']
|
43
|
+
end
|
44
|
+
|
45
|
+
if aka = operation['alsoKnownAs']
|
46
|
+
raise FormatError, "Invalid alsoKnownAs" unless aka.is_a?(Array)
|
47
|
+
raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x.is_a?(String) }
|
48
|
+
raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x =~ %r(\Aat://[^/]+\z) }
|
49
|
+
|
50
|
+
@handles = aka.map { |x| x.gsub('at://', '') }
|
51
|
+
else
|
52
|
+
@handles = []
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'open-uri'
|
3
|
+
require 'net/http'
|
4
|
+
require 'resolv'
|
5
|
+
|
6
|
+
require_relative 'did'
|
7
|
+
require_relative 'document'
|
8
|
+
|
9
|
+
module DIDKit
|
10
|
+
class Resolver
|
11
|
+
RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
|
12
|
+
MAX_REDIRECTS = 5
|
13
|
+
|
14
|
+
attr_accessor :nameserver
|
15
|
+
|
16
|
+
def resolve_handle(handle)
|
17
|
+
domain = handle.gsub(/^@/, '')
|
18
|
+
|
19
|
+
return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
|
20
|
+
|
21
|
+
if dns_did = resolve_handle_by_dns(domain)
|
22
|
+
DID.new(dns_did, :dns)
|
23
|
+
elsif http_did = resolve_handle_by_well_known(domain)
|
24
|
+
DID.new(http_did, :http)
|
25
|
+
else
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def resolve_handle_by_dns(domain)
|
31
|
+
dns_records = Resolv::DNS.open(resolv_options) { |d|
|
32
|
+
d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
|
33
|
+
}
|
34
|
+
|
35
|
+
if record = dns_records.first
|
36
|
+
if string = record.strings.first
|
37
|
+
return parse_did_from_dns(string)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def resolve_handle_by_well_known(domain)
|
45
|
+
resolve_handle_from_url("https://#{domain}/.well-known/atproto-did")
|
46
|
+
end
|
47
|
+
|
48
|
+
def resolve_handle_from_url(url, redirects = 0)
|
49
|
+
url = URI(url) unless url.is_a?(URI)
|
50
|
+
|
51
|
+
response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
|
52
|
+
request = Net::HTTP::Get.new(url)
|
53
|
+
http.request(request)
|
54
|
+
end
|
55
|
+
|
56
|
+
if response.is_a?(Net::HTTPSuccess)
|
57
|
+
if text = response.body
|
58
|
+
return parse_did_from_well_known(text)
|
59
|
+
end
|
60
|
+
elsif response.is_a?(Net::HTTPRedirection) && redirects < MAX_REDIRECTS
|
61
|
+
if location = response['Location']
|
62
|
+
target_url = location.include?('://') ? location : (url.origin + location)
|
63
|
+
return resolve_handle_from_url(target_url, redirects + 1)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
nil
|
68
|
+
rescue StandardError => e
|
69
|
+
nil
|
70
|
+
end
|
71
|
+
|
72
|
+
def resolv_options
|
73
|
+
options = Resolv::DNS::Config.default_config_hash.dup
|
74
|
+
options[:nameserver] = nameserver if nameserver
|
75
|
+
options
|
76
|
+
end
|
77
|
+
|
78
|
+
def parse_did_from_dns(txt)
|
79
|
+
txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil
|
80
|
+
end
|
81
|
+
|
82
|
+
def parse_did_from_well_known(text)
|
83
|
+
text = text.strip
|
84
|
+
text.lines.length == 1 && text =~ /\Adid\:\w+\:.*\z/ ? text : nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def resolve_did(did)
|
88
|
+
did = DID.new(did) if did.is_a?(String)
|
89
|
+
|
90
|
+
did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
|
91
|
+
end
|
92
|
+
|
93
|
+
def resolve_did_plc(did)
|
94
|
+
url = "https://plc.directory/#{did}"
|
95
|
+
json = JSON.parse(URI.open(url).read)
|
96
|
+
Document.new(did, json)
|
97
|
+
end
|
98
|
+
|
99
|
+
def resolve_did_web(did)
|
100
|
+
url = "https://#{did.web_domain}/.well-known/did.json"
|
101
|
+
json = JSON.parse(URI.open(url).read)
|
102
|
+
Document.new(did, json)
|
103
|
+
end
|
104
|
+
|
105
|
+
def get_validated_handle(did_or_doc)
|
106
|
+
document = did_or_doc.is_a?(Document) ? did_or_doc : resolve_did(did_or_doc)
|
107
|
+
|
108
|
+
pick_valid_handle(document.did, document.handles)
|
109
|
+
end
|
110
|
+
|
111
|
+
def pick_valid_handle(did, handles)
|
112
|
+
handles.detect { |h| resolve_handle(h) == did }
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
data/lib/didkit/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: didkit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kuba Suder
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-03-
|
11
|
+
date: 2024-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description:
|
14
14
|
email:
|
@@ -24,6 +24,9 @@ files:
|
|
24
24
|
- lib/didkit/did.rb
|
25
25
|
- lib/didkit/document.rb
|
26
26
|
- lib/didkit/errors.rb
|
27
|
+
- lib/didkit/plc_importer.rb
|
28
|
+
- lib/didkit/plc_operation.rb
|
29
|
+
- lib/didkit/resolver.rb
|
27
30
|
- lib/didkit/version.rb
|
28
31
|
- sig/didkit.rbs
|
29
32
|
homepage: https://github.com/mackuba/didkit
|