didkit 0.0.4 → 0.2.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: 76dd4f3787cf1cc288015511eb35ea6d41ef4c390f85ca67854617fe7c46d710
4
- data.tar.gz: 40ea426b53fa3176dccd4c538cbd40050a3883bb6632af2b45ca1a6f49ac84b7
3
+ metadata.gz: 0170f70f452400a399264cccd61a96a0ef96d570caca53547b2794eb8067d4bd
4
+ data.tar.gz: 68197fe06b766fbe69d09d6d85b8abf59467f09560cb600b9af34bb900efaf6b
5
5
  SHA512:
6
- metadata.gz: a26c81e89e2397bb7690149e143a0de6c9e9635a993e8cd13d572d76b21b38ce37628eb3c3ebc4ea9ffc20b3807f619acb46612a76af9f816b336315a10f7aab
7
- data.tar.gz: 89b4d9eb1ec2b7f5207587cde9b0a2b2db8a5912a92dc1ef996f0bac493996aae8b1340fc673838c404ac28d4ab191be6e7ce3a984f35a83402b9ef489feea8b
6
+ metadata.gz: 367f5acd94953397d5ca72edec0a43f2a96e8dfef3615d16210edc203abe14b1875c1e78316efe351e223c881cbc80d2fd6cf7314753c78caea6c9ccb7ed78d0
7
+ data.tar.gz: 15ade27998fbc1881f28bc07fe7d625f310e686be7f101508460db48df7ca263adfc6731bf9f8bb935363fb23e51f33b1f560183595ba624d1fd7ad4e8dcf00f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.2.0] - 2024-03-19
2
+
3
+ - added `PLCImporter` class, which lets you import operations from PLC in pages of 1000 through the "export" API
4
+ - implemented parsing of all services from DID doc & operations, not only `atproto_pds` (specifically labeller endpoints)
5
+ - allow setting the nameserver in `Resolver` initializer
6
+
7
+ ## [0.1.0] - 2024-03-12
8
+
9
+ - rejecting handles from disallowed domains like `.arpa` or `.test`
10
+ - validating handles with the `.well-known` file having a trailing newline
11
+ - validating handles with `.well-known` address returning a redirect
12
+ - added `#pick_valid_handle` helper
13
+ - allow overriding the nameserver for `Resolv::DNS`
14
+ - other bug fixes
15
+
1
16
  ## [0.0.4] - 2024-03-07
2
17
 
3
18
  - extracted resolving code from `DID` to a new `Resolver` class (`DID` has helper methods to call the resolver)
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).
@@ -0,0 +1,14 @@
1
+ module DIDKit
2
+ module AtHandles
3
+ class FormatError < StandardError
4
+ end
5
+
6
+ def parse_also_known_as(aka)
7
+ raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.is_a?(Array)
8
+ raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.all? { |x| x.is_a?(String) }
9
+ raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.all? { |x| x =~ %r(\Aat://[^/]+\z) }
10
+
11
+ aka.map { |x| x.gsub('at://', '') }
12
+ end
13
+ end
14
+ end
@@ -1,9 +1,17 @@
1
+ require_relative 'at_handles'
2
+ require_relative 'resolver'
3
+ require_relative 'service_record'
4
+ require_relative 'services'
5
+
1
6
  module DIDKit
2
7
  class Document
3
8
  class FormatError < StandardError
4
9
  end
5
10
 
6
- attr_reader :json, :did, :pds_endpoint, :handles
11
+ include AtHandles
12
+ include Services
13
+
14
+ attr_reader :json, :did, :handles, :services
7
15
 
8
16
  def initialize(did, json)
9
17
  raise FormatError, "Missing id field" if json['id'].nil?
@@ -17,29 +25,24 @@ module DIDKit
17
25
  raise FormatError, "Missing service key" if service.nil?
18
26
  raise FormatError, "Invalid service data" unless service.is_a?(Array) && service.all? { |x| x.is_a?(Hash) }
19
27
 
20
- if pds = service.detect { |x| x['id'] == '#atproto_pds' }
21
- raise FormatError, "Missing PDS type" unless pds['type']
22
- raise FormatError, "Invalid PDS type" unless pds['type'] == 'AtprotoPersonalDataServer'
23
- raise FormatError, "Missing PDS endpoint" unless pds['serviceEndpoint']
24
- raise FormatError, "Invalid PDS endpoint" unless pds['serviceEndpoint'].is_a?(String)
25
- raise FormatError, "Invalid PDS endpoint" unless pds['serviceEndpoint'] =~ %r(://)
26
-
27
- @pds_endpoint = pds['serviceEndpoint']
28
- end
29
-
30
- if aka = json['alsoKnownAs']
31
- raise FormatError, "Invalid alsoKnownAs" unless aka.is_a?(Array)
32
- raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x.is_a?(String) }
33
- raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x =~ %r(\Aat://[^/]+\z) }
34
-
35
- @handles = aka.map { |x| x.gsub('at://', '') }
36
- else
37
- @handles = []
38
- end
28
+ @services = service.map { |x|
29
+ id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
30
+
31
+ raise FormatError, "Missing service id" unless id
32
+ raise FormatError, "Invalid service id: #{id.inspect}" unless id.is_a?(String) && id.start_with?('#')
33
+ raise FormatError, "Missing service type" unless type
34
+ raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
35
+ raise FormatError, "Missing service endpoint" unless endpoint
36
+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
37
+
38
+ ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
39
+ }
40
+
41
+ @handles = parse_also_known_as(json['alsoKnownAs'] || [])
39
42
  end
40
43
 
41
44
  def get_validated_handle
42
- Resolver.new.get_validated_handle(self)
45
+ Resolver.new.pick_valid_handle(did, handles)
43
46
  end
44
47
  end
45
48
  end
@@ -0,0 +1,73 @@
1
+ require 'json'
2
+ require 'open-uri'
3
+ require 'time'
4
+
5
+ require_relative 'plc_operation'
6
+
7
+ module DIDKit
8
+ class PLCImporter
9
+ PLC_SERVICE = 'plc.directory'
10
+ MAX_PAGE = 1000
11
+
12
+ attr_accessor :ignore_errors, :last_date
13
+
14
+ def initialize(since: nil)
15
+ if since.to_s == 'beginning'
16
+ @last_date = nil
17
+ elsif since.is_a?(String)
18
+ @last_date = Time.parse(since)
19
+ elsif since
20
+ @last_date = since
21
+ else
22
+ @last_date = Time.now
23
+ @eof = true
24
+ end
25
+
26
+ @ignore_errors = false
27
+ end
28
+
29
+ def plc_service
30
+ PLC_SERVICE
31
+ end
32
+
33
+ def get_export(args = {})
34
+ url = URI("https://#{plc_service}/export")
35
+ url.query = URI.encode_www_form(args)
36
+
37
+ data = URI.open(url).read
38
+ data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) }
39
+ end
40
+
41
+ def fetch_page
42
+ request_time = Time.now
43
+
44
+ query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {}
45
+ rows = get_export(query)
46
+
47
+ operations = rows.filter_map do |json|
48
+ begin
49
+ PLCOperation.new(json)
50
+ rescue PLCOperation::FormatError => e
51
+ ignore_errors ? nil : raise
52
+ end
53
+ end
54
+
55
+ @last_date = operations.last&.created_at || request_time
56
+ @eof = (rows.length < MAX_PAGE)
57
+
58
+ operations
59
+ end
60
+
61
+ def fetch(&block)
62
+ loop do
63
+ operations = fetch_page
64
+ block.call(operations)
65
+ break if eof?
66
+ end
67
+ end
68
+
69
+ def eof?
70
+ !!@eof
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,57 @@
1
+ require 'time'
2
+
3
+ require_relative 'at_handles'
4
+ require_relative 'service_record'
5
+ require_relative 'services'
6
+
7
+ module DIDKit
8
+ class PLCOperation
9
+ class FormatError < StandardError
10
+ end
11
+
12
+ include AtHandles
13
+ include Services
14
+
15
+ attr_reader :json, :did, :created_at, :type, :handles, :services
16
+
17
+ def initialize(json)
18
+ @json = json
19
+ @did = json['did']
20
+ raise FormatError, "Missing DID: #{json}" if @did.nil?
21
+ raise FormatError, "Invalid DID: #{@did}" unless @did.is_a?(String) && @did.start_with?('did:')
22
+
23
+ timestamp = json['createdAt']
24
+ raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
25
+ raise FormatError, "Invalid createdAt: #{timestamp.inspect}" unless timestamp.is_a?(String)
26
+
27
+ @created_at = Time.parse(timestamp)
28
+
29
+ operation = json['operation']
30
+ raise FormatError, "Missing operation key: #{json}" if operation.nil?
31
+ raise FormatError, "Invalid operation data: #{operation.inspect}" unless operation.is_a?(Hash)
32
+
33
+ type = operation['type']
34
+ raise FormatError, "Missing operation type: #{json}" if type.nil?
35
+
36
+ @type = type.to_sym
37
+ return unless @type == :plc_operation
38
+
39
+ services = operation['services']
40
+ raise FormatError, "Missing services key: #{json}" if services.nil?
41
+ raise FormatError, "Invalid services data: #{services}" unless services.is_a?(Hash)
42
+
43
+ @services = services.map { |k, x|
44
+ type, endpoint = x.values_at('type', 'endpoint')
45
+
46
+ raise FormatError, "Missing service type" unless type
47
+ raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
48
+ raise FormatError, "Missing service endpoint" unless endpoint
49
+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
50
+
51
+ ServiceRecord.new(k, type, endpoint)
52
+ }
53
+
54
+ @handles = parse_also_known_as(operation['alsoKnownAs'] || [])
55
+ end
56
+ end
57
+ end
@@ -8,9 +8,20 @@ require_relative 'document'
8
8
 
9
9
  module DIDKit
10
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 initialize(options = {})
17
+ @nameserver = options[:nameserver]
18
+ end
19
+
11
20
  def resolve_handle(handle)
12
21
  domain = handle.gsub(/^@/, '')
13
22
 
23
+ return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
24
+
14
25
  if dns_did = resolve_handle_by_dns(domain)
15
26
  DID.new(dns_did, :dns)
16
27
  elsif http_did = resolve_handle_by_well_known(domain)
@@ -21,13 +32,13 @@ module DIDKit
21
32
  end
22
33
 
23
34
  def resolve_handle_by_dns(domain)
24
- dns_records = Resolv::DNS.open { |d| d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) }
35
+ dns_records = Resolv::DNS.open(resolv_options) { |d|
36
+ d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
37
+ }
25
38
 
26
39
  if record = dns_records.first
27
40
  if string = record.strings.first
28
- if string =~ /^did\=(did\:\w+\:.*)$/
29
- return $1
30
- end
41
+ return parse_did_from_dns(string)
31
42
  end
32
43
  end
33
44
 
@@ -35,7 +46,11 @@ module DIDKit
35
46
  end
36
47
 
37
48
  def resolve_handle_by_well_known(domain)
38
- url = URI("https://#{domain}/.well-known/atproto-did")
49
+ resolve_handle_from_url("https://#{domain}/.well-known/atproto-did")
50
+ end
51
+
52
+ def resolve_handle_from_url(url, redirects = 0)
53
+ url = URI(url) unless url.is_a?(URI)
39
54
 
40
55
  response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
41
56
  request = Net::HTTP::Get.new(url)
@@ -44,9 +59,12 @@ module DIDKit
44
59
 
45
60
  if response.is_a?(Net::HTTPSuccess)
46
61
  if text = response.body
47
- if text.lines.length == 1 && text.start_with?('did:')
48
- return text
49
- end
62
+ return parse_did_from_well_known(text)
63
+ end
64
+ elsif response.is_a?(Net::HTTPRedirection) && redirects < MAX_REDIRECTS
65
+ if location = response['Location']
66
+ target_url = location.include?('://') ? location : (url.origin + location)
67
+ return resolve_handle_from_url(target_url, redirects + 1)
50
68
  end
51
69
  end
52
70
 
@@ -55,6 +73,21 @@ module DIDKit
55
73
  nil
56
74
  end
57
75
 
76
+ def resolv_options
77
+ options = Resolv::DNS::Config.default_config_hash.dup
78
+ options[:nameserver] = nameserver if nameserver
79
+ options
80
+ end
81
+
82
+ def parse_did_from_dns(txt)
83
+ txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil
84
+ end
85
+
86
+ def parse_did_from_well_known(text)
87
+ text = text.strip
88
+ text.lines.length == 1 && text =~ /\Adid\:\w+\:.*\z/ ? text : nil
89
+ end
90
+
58
91
  def resolve_did(did)
59
92
  did = DID.new(did) if did.is_a?(String)
60
93
 
@@ -76,7 +109,11 @@ module DIDKit
76
109
  def get_validated_handle(did_or_doc)
77
110
  document = did_or_doc.is_a?(Document) ? did_or_doc : resolve_did(did_or_doc)
78
111
 
79
- document.handles.detect { |h| resolve_handle(h) == document.did }
112
+ pick_valid_handle(document.did, document.handles)
113
+ end
114
+
115
+ def pick_valid_handle(did, handles)
116
+ handles.detect { |h| resolve_handle(h) == did }
80
117
  end
81
118
  end
82
119
  end
@@ -0,0 +1,20 @@
1
+ require 'uri'
2
+ require_relative 'errors'
3
+
4
+ module DIDKit
5
+ class ServiceRecord
6
+ attr_reader :key, :type, :endpoint
7
+
8
+ def initialize(key, type, endpoint)
9
+ begin
10
+ uri = URI(endpoint)
11
+ rescue URI::Error
12
+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}"
13
+ end
14
+
15
+ @key = key
16
+ @type = type
17
+ @endpoint = endpoint
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ module DIDKit
2
+ module Services
3
+ def get_service(key, type)
4
+ @services&.detect { |s| s.key == key && s.type == type }
5
+ end
6
+
7
+ def pds_endpoint
8
+ @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
9
+ end
10
+
11
+ def labeler_endpoint
12
+ @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DIDKit
4
- VERSION = "0.0.4"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/didkit.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  require_relative "didkit/did"
4
4
  require_relative "didkit/document"
5
+ require_relative "didkit/plc_importer"
6
+ require_relative "didkit/plc_operation"
7
+ require_relative "didkit/resolver"
5
8
  require_relative "didkit/version"
6
9
 
7
10
  module DIDKit
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
4
+ version: 0.2.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-07 00:00:00.000000000 Z
11
+ date: 2024-03-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -21,10 +21,15 @@ files:
21
21
  - LICENSE.txt
22
22
  - README.md
23
23
  - lib/didkit.rb
24
+ - lib/didkit/at_handles.rb
24
25
  - lib/didkit/did.rb
25
26
  - lib/didkit/document.rb
26
27
  - lib/didkit/errors.rb
28
+ - lib/didkit/plc_importer.rb
29
+ - lib/didkit/plc_operation.rb
27
30
  - lib/didkit/resolver.rb
31
+ - lib/didkit/service_record.rb
32
+ - lib/didkit/services.rb
28
33
  - lib/didkit/version.rb
29
34
  - sig/didkit.rbs
30
35
  homepage: https://github.com/mackuba/didkit