acme-client 2.0.26 → 2.0.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f24f8baac91c1bff36891f14b0ec6d1fb3a9265cf771f2ae943b49dac5a96d2e
4
- data.tar.gz: 4f84da04feeea6ae55ab875ab835194bf71182e8cabf7d56e574d79d80e988ae
3
+ metadata.gz: c77ea7f7e3923d5c76ddd26c802ade553e7875f0f95b753a240f06c1e77e3689
4
+ data.tar.gz: 4f7a6f073795c9328b7322bd39f5640fb53e89c2b3790417ea2be2f2f13c5e7e
5
5
  SHA512:
6
- metadata.gz: 1e4a73d22af2fec3e323859f5386308c046c32b443952f81c9ce1811562eae0ec92c0a3bd683c0806bd85ca605bda5468bdf7534b224b94a65a7fe1b1222cce4
7
- data.tar.gz: 84722131dd304475f45459ff78b4a0eda6ccf1a6d0aa2ffbdb2092cb17446ffc62db288ba429ee6a2080779dde273487718cddf27cc7d07b1850210401222b63
6
+ metadata.gz: e12eae043f2e3b8fd505b88980f45336aee50013c23297ef8dd6f3f58cb427c62be9d0d1098dee4242fe258bc12b3bf417b22e3be772c3bcf8d6eb9b15748b75
7
+ data.tar.gz: 24fb6340f56d6eb998d57706dbfb92f92a64f488157410d0997e38bda9c009e541c4a2e5686d936d1be5a5c40aee39bcb3d8953edea86ddea4d0915c87344cfa
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## `2.0.28`
2
+
3
+ * Make [Retry-After](https://datatracker.ietf.org/doc/html/rfc8555/#section-6.6) accessible from RateLimited#retry_after exceptions
4
+
5
+ ## `2.0.27`
6
+
7
+ * Add support for Renewal Information (ARI) (RFC 9773)
8
+
1
9
  ## `2.0.26`
2
10
 
3
11
  * Add support for dns-account-01 challenge (RFC draft-ietf-acme-dns-account-label-01)
@@ -0,0 +1,8 @@
1
+ class Acme::Client::Error::RateLimited < Acme::Client::Error::ServerError
2
+ attr_reader :retry_after
3
+
4
+ def initialize(message, retry_after = 10)
5
+ super(message)
6
+ @retry_after = retry_after.nil? ? 10 : retry_after.to_i
7
+ end
8
+ end
@@ -12,6 +12,7 @@ class Acme::Client::Error < StandardError
12
12
  class OrderNotReloadable < ClientError; end
13
13
 
14
14
  class ServerError < Acme::Client::Error; end
15
+ class AlreadyReplaced < ServerError; end
15
16
  class AlreadyRevoked < ServerError; end
16
17
  class BadCSR < ServerError; end
17
18
  class BadNonce < ServerError; end
@@ -36,6 +37,7 @@ class Acme::Client::Error < StandardError
36
37
  class IncorrectResponse < ServerError; end
37
38
 
38
39
  ACME_ERRORS = {
40
+ 'urn:ietf:params:acme:error:alreadyReplaced' => AlreadyReplaced,
39
41
  'urn:ietf:params:acme:error:alreadyRevoked' => AlreadyRevoked,
40
42
  'urn:ietf:params:acme:error:badCSR' => BadCSR,
41
43
  'urn:ietf:params:acme:error:badNonce' => BadNonce,
@@ -101,6 +101,9 @@ module Acme::Client::HTTPClient
101
101
  end
102
102
 
103
103
  def raise_on_error!
104
+ if error_class == Acme::Client::Error::RateLimited
105
+ raise error_class.new(error_message, env.response_headers['Retry-After'])
106
+ end
104
107
  raise error_class, error_message
105
108
  end
106
109
 
@@ -7,7 +7,8 @@ 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 = {
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Acme::Client::Resources::RenewalInfo
4
+ attr_reader :suggested_window, :explanation_url, :retry_after
5
+
6
+ def initialize(client, **arguments)
7
+ @client = client
8
+ assign_attributes(**arguments)
9
+ end
10
+
11
+ def suggested_window_start
12
+ suggested_window&.fetch('start', nil)
13
+ end
14
+
15
+ def suggested_window_end
16
+ suggested_window&.fetch('end', nil)
17
+ end
18
+
19
+ def suggested_renewal_time
20
+ return nil unless suggested_window_start && suggested_window_end
21
+
22
+ start_time = DateTime.rfc3339(suggested_window_start).to_time
23
+ end_time = DateTime.rfc3339(suggested_window_end).to_time
24
+ window_duration = end_time - start_time
25
+
26
+ random_offset = rand(0.0..window_duration)
27
+ selected_time = start_time + random_offset
28
+
29
+ selected_time > Time.now ? selected_time : Time.now
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ suggested_window: suggested_window,
35
+ explanation_url: explanation_url,
36
+ retry_after: retry_after
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def assign_attributes(suggested_window:, explanation_url: nil, retry_after: nil)
43
+ @suggested_window = suggested_window
44
+ @explanation_url = explanation_url
45
+ @retry_after = retry_after
46
+ end
47
+ 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'
@@ -32,4 +32,41 @@ module Acme::Client::Util
32
32
  raise ArgumentError, 'priv must be EC or RSA'
33
33
  end
34
34
  end
35
+
36
+ # Generates a certificate identifier for ACME Renewal Information (ARI) as per RFC 9773.
37
+ # The identifier is constructed by extracting the Authority Key Identifier (AKI) from
38
+ # the certificate extension, and the DER-encoded serial number (without tag and length bytes).
39
+ # Both values are base64url-encoded and concatenated with a period separator.
40
+ #
41
+ # certificate - An OpenSSL::X509::Certificate instance or PEM string.
42
+ #
43
+ # Returns a string in the format: base64url(AKI).base64url(serial)
44
+ def ari_certificate_identifier(certificate)
45
+ cert = if certificate.is_a?(OpenSSL::X509::Certificate)
46
+ certificate
47
+ else
48
+ OpenSSL::X509::Certificate.new(certificate)
49
+ end
50
+
51
+ aki_ext = cert.extensions.find { |ext| ext.oid == 'authorityKeyIdentifier' }
52
+ raise ArgumentError, 'Certificate does not have an Authority Key Identifier extension' unless aki_ext
53
+
54
+ aki_value = aki_ext.value
55
+ hex_string = if aki_value =~ /keyid:([0-9A-Fa-f:]+)/
56
+ $1
57
+ elsif aki_value =~ /^[0-9A-Fa-f:]+$/
58
+ aki_value
59
+ else
60
+ raise ArgumentError, 'Could not parse Authority Key Identifier'
61
+ end
62
+
63
+ key_identifier = hex_string.split(':').map { |hex| hex.to_i(16).chr }.join
64
+ serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
65
+ serial_value = OpenSSL::ASN1.decode(serial_der).value.to_s(2)
66
+
67
+ aki_b64 = urlsafe_base64(key_identifier)
68
+ serial_b64 = urlsafe_base64(serial_value)
69
+
70
+ "#{aki_b64}.#{serial_b64}"
71
+ end
35
72
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.26'.freeze
5
+ VERSION = '2.0.28'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -20,6 +20,7 @@ require 'acme/client/self_sign_certificate'
20
20
  require 'acme/client/resources'
21
21
  require 'acme/client/jwk'
22
22
  require 'acme/client/error'
23
+ require 'acme/client/error/rate_limited'
23
24
  require 'acme/client/util'
24
25
  require 'acme/client/chain_identifier'
25
26
 
@@ -135,12 +136,13 @@ class Acme::Client
135
136
  @kid ||= account.kid
136
137
  end
137
138
 
138
- def new_order(identifiers:, not_before: nil, not_after: nil, profile: nil)
139
+ def new_order(identifiers:, not_before: nil, not_after: nil, profile: nil, replaces: nil)
139
140
  payload = {}
140
141
  payload['identifiers'] = prepare_order_identifiers(identifiers)
141
142
  payload['notBefore'] = not_before if not_before
142
143
  payload['notAfter'] = not_after if not_after
143
144
  payload['profile'] = profile if profile
145
+ payload['replaces'] = replaces if replaces
144
146
 
145
147
  response = post(endpoint_for(:new_order), payload: payload)
146
148
  arguments = attributes_from_order_response(response)
@@ -223,6 +225,15 @@ class Acme::Client
223
225
  response.success?
224
226
  end
225
227
 
228
+ def renewal_info(certificate:)
229
+ cert_id = Acme::Client::Util.ari_certificate_identifier(certificate)
230
+ renewal_info_url = URI.join(endpoint_for(:renewal_info).to_s + '/', cert_id).to_s
231
+
232
+ response = get(renewal_info_url)
233
+ attributes = attributes_from_renewal_info_response(response)
234
+ Acme::Client::Resources::RenewalInfo.new(self, **attributes)
235
+ end
236
+
226
237
  def get_nonce
227
238
  http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce), options: @connection_options)
228
239
  response = http_client.head(nil, nil)
@@ -320,6 +331,16 @@ class Acme::Client
320
331
  extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
321
332
  end
322
333
 
334
+ def attributes_from_renewal_info_response(response)
335
+ attributes = extract_attributes(
336
+ response.body,
337
+ [:suggested_window, 'suggestedWindow'],
338
+ [:explanation_url, 'explanationURL']
339
+ )
340
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
341
+ attributes
342
+ end
343
+
323
344
  def extract_attributes(input, *attributes)
324
345
  attributes
325
346
  .map {|fields| Array(fields) }
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acme-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.26
4
+ version: 2.0.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charles Barbier
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-24 00:00:00.000000000 Z
10
+ date: 2025-11-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -155,6 +155,7 @@ files:
155
155
  - lib/acme/client/certificate_request/ec_key_patch.rb
156
156
  - lib/acme/client/chain_identifier.rb
157
157
  - lib/acme/client/error.rb
158
+ - lib/acme/client/error/rate_limited.rb
158
159
  - lib/acme/client/http_client.rb
159
160
  - lib/acme/client/jwk.rb
160
161
  - lib/acme/client/jwk/base.rb
@@ -172,6 +173,7 @@ files:
172
173
  - lib/acme/client/resources/challenges/unsupported_challenge.rb
173
174
  - lib/acme/client/resources/directory.rb
174
175
  - lib/acme/client/resources/order.rb
176
+ - lib/acme/client/resources/renewal_info.rb
175
177
  - lib/acme/client/self_sign_certificate.rb
176
178
  - lib/acme/client/util.rb
177
179
  - lib/acme/client/version.rb