acme-client 2.0.9 → 2.0.31

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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Authorization
4
- attr_reader :url, :identifier, :domain, :expires, :status, :wildcard
4
+ attr_reader :url, :identifier, :domain, :expires, :status, :wildcard, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -38,6 +38,13 @@ class Acme::Client::Resources::Authorization
38
38
  end
39
39
  alias_method :dns, :dns01
40
40
 
41
+ def dns_account_01
42
+ @dns_account_01 ||= challenges.find { |challenge|
43
+ challenge.is_a?(Acme::Client::Resources::Challenges::DNSAccount01)
44
+ }
45
+ end
46
+ alias_method :dns_account, :dns_account_01
47
+
41
48
  def to_h
42
49
  {
43
50
  url: url,
@@ -45,7 +52,8 @@ class Acme::Client::Resources::Authorization
45
52
  status: status,
46
53
  expires: expires,
47
54
  challenges: @challenges,
48
- wildcard: wildcard
55
+ wildcard: wildcard,
56
+ retry_after: retry_after
49
57
  }
50
58
  end
51
59
 
@@ -56,13 +64,13 @@ class Acme::Client::Resources::Authorization
56
64
  type: attributes.fetch('type'),
57
65
  status: attributes.fetch('status'),
58
66
  url: attributes.fetch('url'),
59
- token: attributes.fetch('token'),
67
+ token: attributes.fetch('token', nil),
60
68
  error: attributes['error']
61
69
  }
62
70
  Acme::Client::Resources::Challenges.new(@client, **arguments)
63
71
  end
64
72
 
65
- def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false)
73
+ def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false, retry_after: nil)
66
74
  @url = url
67
75
  @identifier = identifier
68
76
  @domain = identifier.fetch('value')
@@ -70,5 +78,7 @@ class Acme::Client::Resources::Authorization
70
78
  @expires = expires
71
79
  @challenges = challenges
72
80
  @wildcard = wildcard
81
+ @retry_after = retry_after
82
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
73
83
  end
74
84
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Challenges::Base
4
- attr_reader :status, :url, :token, :error
4
+ attr_reader :status, :url, :token, :error, :validated, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -28,8 +28,17 @@ class Acme::Client::Resources::Challenges::Base
28
28
  true
29
29
  end
30
30
 
31
+ def typed_error
32
+ return nil unless error
33
+
34
+ error_type = error['type']
35
+ error_detail = error['detail'] || 'Unknown error'
36
+ error_class = Acme::Client::Error::ACME_ERRORS.fetch(error_type, Acme::Client::Error)
37
+ error_class.new(error_detail)
38
+ end
39
+
31
40
  def to_h
32
- { status: status, url: url, token: token, error: error }
41
+ { status: status, url: url, token: token, error: error, validated: validated, retry_after: retry_after }
33
42
  end
34
43
 
35
44
  private
@@ -40,10 +49,13 @@ class Acme::Client::Resources::Challenges::Base
40
49
  ).to_h
41
50
  end
42
51
 
43
- def assign_attributes(status:, url:, token:, error: nil)
52
+ def assign_attributes(status:, url:, token:, error: nil, validated: nil, retry_after: nil)
44
53
  @status = status
45
54
  @url = url
46
55
  @token = token
47
56
  @error = error
57
+ @validated = validated
58
+ @retry_after = retry_after
59
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
48
60
  end
49
61
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DNS-Account-01 challenge following draft-ietf-acme-dns-account-label-01
4
+ # Enables multiple ACME clients to validate the same domain concurrently
5
+ class Acme::Client::Resources::Challenges::DNSAccount01 < Acme::Client::Resources::Challenges::Base
6
+ CHALLENGE_TYPE = 'dns-account-01'.freeze
7
+ RECORD_PREFIX = '_'.freeze
8
+ RECORD_SUFFIX = '._acme-challenge'.freeze
9
+ RECORD_TYPE = 'TXT'.freeze
10
+ DIGEST = OpenSSL::Digest::SHA256
11
+ BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'.freeze # RFC 4648 lowercase alphabet
12
+
13
+ # Generates account-specific DNS record name using SHA256(account_url) + Base32
14
+ # Format: _<base32_label>._acme-challenge
15
+ def record_name
16
+ digest = DIGEST.digest(@client.kid)[0, 10] # First 10 octets for label
17
+ bits = digest.unpack1('B*')
18
+ label = bits.scan(/.{5}/).map { |chunk| BASE32_ALPHABET[chunk.to_i(2)] }.join
19
+ "#{RECORD_PREFIX}#{label}#{RECORD_SUFFIX}"
20
+ end
21
+
22
+ def record_type
23
+ RECORD_TYPE
24
+ end
25
+
26
+ def record_content
27
+ Acme::Client::Util.urlsafe_base64(DIGEST.digest(key_authorization))
28
+ end
29
+ end
30
+
@@ -4,11 +4,13 @@ module Acme::Client::Resources::Challenges
4
4
  require 'acme/client/resources/challenges/base'
5
5
  require 'acme/client/resources/challenges/http01'
6
6
  require 'acme/client/resources/challenges/dns01'
7
+ require 'acme/client/resources/challenges/dns_account01'
7
8
  require 'acme/client/resources/challenges/unsupported_challenge'
8
9
 
9
10
  CHALLENGE_TYPES = {
10
11
  'http-01' => Acme::Client::Resources::Challenges::HTTP01,
11
- 'dns-01' => Acme::Client::Resources::Challenges::DNS01
12
+ 'dns-01' => Acme::Client::Resources::Challenges::DNS01,
13
+ 'dns-account-01' => Acme::Client::Resources::Challenges::DNSAccount01
12
14
  }
13
15
 
14
16
  def self.new(client, type:, **arguments)
@@ -7,22 +7,25 @@ class Acme::Client::Resources::Directory
7
7
  new_order: 'newOrder',
8
8
  new_authz: 'newAuthz',
9
9
  revoke_certificate: 'revokeCert',
10
- key_change: 'keyChange'
10
+ key_change: 'keyChange',
11
+ renewal_info: 'renewalInfo'
11
12
  }
12
13
 
13
14
  DIRECTORY_META = {
14
15
  terms_of_service: 'termsOfService',
15
16
  website: 'website',
16
17
  caa_identities: 'caaIdentities',
17
- external_account_required: 'externalAccountRequired'
18
+ external_account_required: 'externalAccountRequired',
19
+ profiles: 'profiles'
18
20
  }
19
21
 
20
- def initialize(url, connection_options)
21
- @url, @connection_options = url, connection_options
22
+ def initialize(client, **arguments)
23
+ @client = client
24
+ assign_attributes(**arguments)
22
25
  end
23
26
 
24
27
  def endpoint_for(key)
25
- directory.fetch(key) do |missing_key|
28
+ @directory.fetch(key) do |missing_key|
26
29
  raise Acme::Client::Error::UnsupportedOperation,
27
30
  "Directory at #{@url} does not include `#{missing_key}`"
28
31
  end
@@ -44,37 +47,21 @@ class Acme::Client::Resources::Directory
44
47
  meta[DIRECTORY_META[:external_account_required]]
45
48
  end
46
49
 
50
+ def profiles
51
+ meta[DIRECTORY_META[:profiles]]
52
+ end
53
+
47
54
  def meta
48
- directory[:meta]
55
+ @directory[:meta]
49
56
  end
50
57
 
51
58
  private
52
59
 
53
- def directory
54
- @directory ||= load_directory
55
- end
56
-
57
- def load_directory
58
- body = fetch_directory
59
- result = {}
60
- result[:meta] = body.delete('meta')
60
+ def assign_attributes(directory:)
61
+ @directory = {}
62
+ @directory[:meta] = directory.delete('meta')
61
63
  DIRECTORY_RESOURCES.each do |key, entry|
62
- result[key] = URI(body[entry]) if body[entry]
63
- end
64
- result
65
- rescue JSON::ParserError => exception
66
- raise Acme::Client::Error::InvalidDirectory,
67
- "Invalid directory url\n#{@directory} did not return a valid directory\n#{exception.inspect}"
68
- end
69
-
70
- def fetch_directory
71
- connection = Faraday.new(url: @directory, **@connection_options) do |configuration|
72
- configuration.use Acme::Client::FaradayMiddleware, client: nil, mode: nil
73
-
74
- configuration.adapter Faraday.default_adapter
64
+ @directory[key] = URI(directory[entry]) if directory[entry]
75
65
  end
76
- connection.headers[:user_agent] = Acme::Client::USER_AGENT
77
- response = connection.get(@url)
78
- response.body
79
66
  end
80
67
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Order
4
- attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url
4
+ attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url, :profile, :replaces, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -9,6 +9,8 @@ class Acme::Client::Resources::Order
9
9
  end
10
10
 
11
11
  def reload
12
+ raise Acme::Client::Error::OrderUrlNil, 'Cannot reload order with nil url.' if url.nil?
13
+
12
14
  assign_attributes(**@client.order(url: url).to_h)
13
15
  true
14
16
  end
@@ -32,6 +34,18 @@ class Acme::Client::Resources::Order
32
34
  end
33
35
  end
34
36
 
37
+ def renew(replaces: nil, **arguments)
38
+ replaces ||= renewal_info.ari_id
39
+
40
+ @client.new_order(replaces: replaces, **to_h.slice(:identifiers, :profile).merge(arguments))
41
+ end
42
+
43
+ def renewal_info(certificate: nil, ari_id: nil)
44
+ certificate ||= self.certificate if ari_id.nil?
45
+
46
+ @client.renewal_info(certificate:, ari_id:)
47
+ end
48
+
35
49
  def to_h
36
50
  {
37
51
  url: url,
@@ -40,19 +54,26 @@ class Acme::Client::Resources::Order
40
54
  finalize_url: finalize_url,
41
55
  authorization_urls: authorization_urls,
42
56
  identifiers: identifiers,
43
- certificate_url: certificate_url
57
+ certificate_url: certificate_url,
58
+ profile: profile,
59
+ replaces: replaces,
60
+ retry_after: retry_after
44
61
  }
45
62
  end
46
63
 
47
64
  private
48
65
 
49
- def assign_attributes(url:, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil)
50
- @url = url
66
+ def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil, profile: nil, replaces: nil, retry_after: nil) # rubocop:disable Layout/LineLength,Metrics/ParameterLists
67
+ @url = url unless url.nil?
51
68
  @status = status
52
69
  @expires = expires
53
70
  @finalize_url = finalize_url
54
71
  @authorization_urls = authorization_urls
55
72
  @identifiers = identifiers
56
73
  @certificate_url = certificate_url
74
+ @profile = profile
75
+ @replaces = replaces
76
+ @retry_after = retry_after
77
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
57
78
  end
58
79
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Acme::Client::Resources::RenewalInfo
4
+ attr_reader :ari_id, :suggested_window, :explanation_url, :retry_after, :retry_after_time
5
+
6
+ def initialize(client, **arguments)
7
+ @client = client
8
+ assign_attributes(**arguments)
9
+ end
10
+
11
+ def reload
12
+ assign_attributes(**@client.renewal_info(ari_id: ari_id).to_h)
13
+ end
14
+
15
+ def suggested_window_start
16
+ suggested_window&.fetch('start', nil)
17
+ end
18
+
19
+ def suggested_window_end
20
+ suggested_window&.fetch('end', nil)
21
+ end
22
+
23
+ def suggested_renewal_time
24
+ return nil unless suggested_window_start && suggested_window_end
25
+
26
+ start_time = DateTime.rfc3339(suggested_window_start).to_time
27
+ end_time = DateTime.rfc3339(suggested_window_end).to_time
28
+ window_duration = end_time - start_time
29
+
30
+ random_offset = rand(0.0..window_duration)
31
+ selected_time = start_time + random_offset
32
+
33
+ selected_time > Time.now ? selected_time : Time.now
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ ari_id: ari_id,
39
+ suggested_window: suggested_window,
40
+ explanation_url: explanation_url,
41
+ retry_after: retry_after
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def assign_attributes(ari_id:, suggested_window:, explanation_url: nil, retry_after: nil)
48
+ @ari_id = ari_id
49
+ @suggested_window = suggested_window
50
+ @explanation_url = explanation_url
51
+ @retry_after = retry_after
52
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
53
+ end
54
+ end
@@ -5,3 +5,4 @@ require 'acme/client/resources/account'
5
5
  require 'acme/client/resources/order'
6
6
  require 'acme/client/resources/authorization'
7
7
  require 'acme/client/resources/challenges'
8
+ require 'acme/client/resources/renewal_info'
@@ -1,4 +1,24 @@
1
+ require 'time'
2
+
1
3
  module Acme::Client::Util
4
+ extend self
5
+
6
+ # Parses a Retry-After header value into a Time.
7
+ # RFC 7231 §7.1.3: the value is either delay-seconds or an HTTP-date.
8
+ # Returns a Time, or nil if the value is nil or unparseable.
9
+ def parse_retry_after(value)
10
+ return nil if value.nil?
11
+
12
+ value = value.to_s
13
+ Integer(value, 10).then { |seconds| Time.now + seconds }
14
+ rescue ArgumentError, RangeError
15
+ begin
16
+ Time.httpdate(value)
17
+ rescue ArgumentError
18
+ nil
19
+ end
20
+ end
21
+
2
22
  def urlsafe_base64(data)
3
23
  Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
4
24
  end
@@ -31,5 +51,40 @@ module Acme::Client::Util
31
51
  end
32
52
  end
33
53
 
34
- extend self
54
+ # Generates a certificate identifier for ACME Renewal Information (ARI) as per RFC 9773.
55
+ # The identifier is constructed by extracting the Authority Key Identifier (AKI) from
56
+ # the certificate extension, and the DER-encoded serial number (without tag and length bytes).
57
+ # Both values are base64url-encoded and concatenated with a period separator.
58
+ #
59
+ # certificate - An OpenSSL::X509::Certificate instance or PEM string.
60
+ #
61
+ # Returns a string in the format: base64url(AKI).base64url(serial)
62
+ def ari_certificate_identifier(certificate)
63
+ cert = if certificate.is_a?(OpenSSL::X509::Certificate)
64
+ certificate
65
+ else
66
+ OpenSSL::X509::Certificate.new(certificate)
67
+ end
68
+
69
+ aki_ext = cert.extensions.find { |ext| ext.oid == 'authorityKeyIdentifier' }
70
+ raise ArgumentError, 'Certificate does not have an Authority Key Identifier extension' unless aki_ext
71
+
72
+ aki_value = aki_ext.value
73
+ hex_string = if aki_value =~ /keyid:([0-9A-Fa-f:]+)/
74
+ $1
75
+ elsif aki_value =~ /^[0-9A-Fa-f:]+$/
76
+ aki_value
77
+ else
78
+ raise ArgumentError, 'Could not parse Authority Key Identifier'
79
+ end
80
+
81
+ key_identifier = hex_string.split(':').map { |hex| hex.to_i(16).chr }.join
82
+ serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
83
+ serial_value = OpenSSL::ASN1.decode(serial_der).value.to_s(2)
84
+
85
+ aki_b64 = urlsafe_base64(key_identifier)
86
+ serial_b64 = urlsafe_base64(serial_value)
87
+
88
+ "#{aki_b64}.#{serial_b64}"
89
+ end
35
90
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.9'.freeze
5
+ VERSION = '2.0.31'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'faraday/retry'
4
5
  require 'json'
5
6
  require 'openssl'
6
7
  require 'digest'
@@ -13,12 +14,13 @@ module Acme; end
13
14
  class Acme::Client; end
14
15
 
15
16
  require 'acme/client/version'
17
+ require 'acme/client/http_client'
16
18
  require 'acme/client/certificate_request'
17
19
  require 'acme/client/self_sign_certificate'
18
20
  require 'acme/client/resources'
19
- require 'acme/client/faraday_middleware'
20
21
  require 'acme/client/jwk'
21
22
  require 'acme/client/error'
23
+ require 'acme/client/error/rate_limited'
22
24
  require 'acme/client/util'
23
25
  require 'acme/client/chain_identifier'
24
26
 
@@ -43,13 +45,14 @@ class Acme::Client
43
45
 
44
46
  @kid, @connection_options = kid, connection_options
45
47
  @bad_nonce_retry = bad_nonce_retry
46
- @directory = Acme::Client::Resources::Directory.new(URI(directory), @connection_options)
48
+ @directory_url = URI(directory)
47
49
  @nonces ||= []
48
50
  end
49
51
 
50
52
  attr_reader :jwk, :nonces
51
53
 
52
- def new_account(contact:, terms_of_service_agreed: nil)
54
+ def new_account(contact:, terms_of_service_agreed: nil, external_account_binding: nil)
55
+ new_account_endpoint = endpoint_for(:new_account)
53
56
  payload = {
54
57
  contact: Array(contact)
55
58
  }
@@ -58,7 +61,18 @@ class Acme::Client
58
61
  payload[:termsOfServiceAgreed] = terms_of_service_agreed
59
62
  end
60
63
 
61
- response = post(endpoint_for(:new_account), payload: payload, mode: :jws)
64
+ if external_account_binding
65
+ kid, hmac_key = external_account_binding.values_at(:kid, :hmac_key)
66
+ if kid.nil? || hmac_key.nil?
67
+ raise ArgumentError, 'must specify kid and hmac_key key for external_account_binding'
68
+ end
69
+
70
+ hmac = Acme::Client::JWK::HMAC.new(Base64.urlsafe_decode64(hmac_key))
71
+ external_account_payload = hmac.jws(header: { kid: kid, url: new_account_endpoint }, payload: @jwk)
72
+ payload[:externalAccountBinding] = JSON.parse(external_account_payload)
73
+ end
74
+
75
+ response = post(new_account_endpoint, payload: payload, mode: :jws)
62
76
  @kid = response.headers.fetch(:location)
63
77
 
64
78
  if response.body.nil? || response.body.empty?
@@ -122,11 +136,13 @@ class Acme::Client
122
136
  @kid ||= account.kid
123
137
  end
124
138
 
125
- def new_order(identifiers:, not_before: nil, not_after: nil)
139
+ def new_order(identifiers:, not_before: nil, not_after: nil, profile: nil, replaces: nil)
126
140
  payload = {}
127
141
  payload['identifiers'] = prepare_order_identifiers(identifiers)
128
142
  payload['notBefore'] = not_before if not_before
129
143
  payload['notAfter'] = not_after if not_after
144
+ payload['profile'] = profile if profile
145
+ payload['replaces'] = replaces if replaces
130
146
 
131
147
  response = post(endpoint_for(:new_order), payload: payload)
132
148
  arguments = attributes_from_order_response(response)
@@ -209,35 +225,69 @@ class Acme::Client
209
225
  response.success?
210
226
  end
211
227
 
228
+ def renewal_info(certificate: nil, ari_id: nil)
229
+ if certificate.nil? && ari_id.nil?
230
+ raise ArgumentError, 'must specify certificate or ari_id'
231
+ end
232
+
233
+ ari_id ||= Acme::Client::Util.ari_certificate_identifier(certificate)
234
+
235
+ renewal_info_url = URI.join(endpoint_for(:renewal_info).to_s + '/', ari_id).to_s
236
+
237
+ response = get(renewal_info_url)
238
+ attributes = attributes_from_renewal_info_response(response)
239
+ Acme::Client::Resources::RenewalInfo.new(self, ari_id: ari_id, **attributes)
240
+ end
241
+
212
242
  def get_nonce
213
- connection = new_connection(endpoint: endpoint_for(:new_nonce))
214
- response = connection.head(nil, nil, 'User-Agent' => USER_AGENT)
243
+ http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce), options: @connection_options)
244
+ response = http_client.head(nil, nil)
215
245
  nonces << response.headers['replay-nonce']
216
246
  true
217
247
  end
218
248
 
249
+ def directory
250
+ @directory ||= load_directory
251
+ end
252
+
219
253
  def meta
220
- @directory.meta
254
+ directory.meta
221
255
  end
222
256
 
223
257
  def terms_of_service
224
- @directory.terms_of_service
258
+ directory.terms_of_service
225
259
  end
226
260
 
227
261
  def website
228
- @directory.website
262
+ directory.website
229
263
  end
230
264
 
231
265
  def caa_identities
232
- @directory.caa_identities
266
+ directory.caa_identities
233
267
  end
234
268
 
235
269
  def external_account_required
236
- @directory.external_account_required
270
+ directory.external_account_required
271
+ end
272
+
273
+ def profiles
274
+ directory.profiles
237
275
  end
238
276
 
239
277
  private
240
278
 
279
+ def load_directory
280
+ Acme::Client::Resources::Directory.new(self, directory: fetch_directory)
281
+ end
282
+
283
+ def fetch_directory
284
+ response = get(@directory_url)
285
+ response.body
286
+ rescue JSON::ParserError => exception
287
+ raise Acme::Client::Error::InvalidDirectory,
288
+ "Invalid directory url\n#{@directory_url} did not return a valid directory\n#{exception.inspect}"
289
+ end
290
+
241
291
  def prepare_order_identifiers(identifiers)
242
292
  if identifiers.is_a?(Hash)
243
293
  [identifiers]
@@ -257,6 +307,7 @@ class Acme::Client
257
307
  response.body,
258
308
  :status,
259
309
  [:term_of_service, 'termsOfServiceAgreed'],
310
+ :orders,
260
311
  :contact
261
312
  )
262
313
  end
@@ -269,19 +320,36 @@ class Acme::Client
269
320
  [:finalize_url, 'finalize'],
270
321
  [:authorization_urls, 'authorizations'],
271
322
  [:certificate_url, 'certificate'],
272
- :identifiers
323
+ :identifiers,
324
+ :profile,
325
+ :replaces
273
326
  )
274
327
 
275
328
  attributes[:url] = response.headers[:location] if response.headers[:location]
329
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
276
330
  attributes
277
331
  end
278
332
 
279
333
  def attributes_from_authorization_response(response)
280
- extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
334
+ attributes = extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
335
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
336
+ attributes
281
337
  end
282
338
 
283
339
  def attributes_from_challenge_response(response)
284
- extract_attributes(response.body, :status, :url, :token, :type, :error)
340
+ attributes = extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
341
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
342
+ attributes
343
+ end
344
+
345
+ def attributes_from_renewal_info_response(response)
346
+ attributes = extract_attributes(
347
+ response.body,
348
+ [:suggested_window, 'suggestedWindow'],
349
+ [:explanation_url, 'explanationURL']
350
+ )
351
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
352
+ attributes
285
353
  end
286
354
 
287
355
  def extract_attributes(input, *attributes)
@@ -303,7 +371,7 @@ class Acme::Client
303
371
  connection.post(url, nil)
304
372
  end
305
373
 
306
- def get(url, mode: :kid)
374
+ def get(url, mode: :get)
307
375
  connection = connection_for(url: url, mode: mode)
308
376
  connection.get(url)
309
377
  end
@@ -319,41 +387,15 @@ class Acme::Client
319
387
  def connection_for(url:, mode:)
320
388
  uri = URI(url)
321
389
  endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}"
390
+
322
391
  @connections ||= {}
323
392
  @connections[mode] ||= {}
324
- @connections[mode][endpoint] ||= new_acme_connection(endpoint: endpoint, mode: mode)
325
- end
326
-
327
- def new_acme_connection(endpoint:, mode:)
328
- new_connection(endpoint: endpoint) do |configuration|
329
- configuration.use Acme::Client::FaradayMiddleware, client: self, mode: mode
330
- end
331
- end
332
-
333
- def new_connection(endpoint:)
334
- Faraday.new(endpoint, **@connection_options) do |configuration|
335
- if @bad_nonce_retry > 0
336
- configuration.request(:retry,
337
- max: @bad_nonce_retry,
338
- methods: Faraday::Connection::METHODS,
339
- exceptions: [Acme::Client::Error::BadNonce])
340
- end
341
- yield(configuration) if block_given?
342
- configuration.adapter Faraday.default_adapter
343
- end
344
- end
345
-
346
- def fetch_chain(response, limit = 10)
347
- links = response.headers['link']
348
- if limit.zero? || links.nil? || links['up'].nil?
349
- []
350
- else
351
- issuer = get(links['up'])
352
- [OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)]
353
- end
393
+ @connections[mode][endpoint] ||= Acme::Client::HTTPClient.new_acme_connection(
394
+ url: URI(endpoint), mode: mode, client: self, options: @connection_options, bad_nonce_retry: @bad_nonce_retry
395
+ )
354
396
  end
355
397
 
356
398
  def endpoint_for(key)
357
- @directory.endpoint_for(key)
399
+ directory.endpoint_for(key)
358
400
  end
359
401
  end