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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d974c618ebe4702ec35b8f4e1465b3cc0664d1cf932fd8739ac4371f7c20caa2
4
- data.tar.gz: 66479727000f96ef26d09074bd97cd59d43f14388ea213f25deb846c6e75cb3d
3
+ metadata.gz: 7744ae1dc9ba4ca678d641139ffa94931130ee7f91fa93d3ee89e3d6e196ba8d
4
+ data.tar.gz: 4111e824027b646aa8b3a21fb3032dfdba26019b6fecaf3f40f2120dfa417e52
5
5
  SHA512:
6
- metadata.gz: b3b84e04e7be85c0af2a544173a14338870fc3163727dd9341a1c76d83ce6397c03f4f28284549c5c07c2c3625e018b81c7a4149c80dac8e817f01f7b724c042
7
- data.tar.gz: e476000c6bbc05a3e5664e2fb44a8bb19e302c2b13d880bfc9d98a2144916b7982d914d41beea55e5775f55a1a3d01a9bd0854ff4cfa9274d86aba4a406441e1
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
- # DidKit
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
- **TODO** - not much yet :)
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 © 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
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
- domain = handle.gsub(/^@/, '')
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
- type == :plc ? resolve_did_plc : resolve_did_web
30
+ Resolver.new.resolve_did(self)
75
31
  end
76
32
 
77
- def web_domain
78
- did.gsub(/^did\:web\:/, '') if type == :web
33
+ def get_validated_handle
34
+ Resolver.new.get_validated_handle(self)
79
35
  end
80
36
 
81
- def resolve_did_plc
82
- url = "https://plc.directory/#{did}"
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 resolve_did_web
88
- url = "https://#{web_domain}/.well-known/did.json"
89
- json = JSON.parse(URI.open(url).read)
90
- Document.new(self, json)
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
@@ -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, "Id field doesn't match expected DID" unless json['id'] == did.to_s
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DIDKit
4
- VERSION = "0.0.3"
4
+ VERSION = "0.1.0"
5
5
  end
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.3
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-06 00:00:00.000000000 Z
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