acme-client 2.0.25 → 2.0.27

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: 57864d566a88b4298a243bfaa6f34b5556a7aa1f36f41fd4e61636d0e7fae74e
4
- data.tar.gz: c68a9476baa8fad93f9373e16d3dad6249edb2cc7f007d0d1bf4386cf0cf7f6e
3
+ metadata.gz: d41b8df6f16f62b2bd33a0fd960d7675c02e1d065fb3bf3466b58f59aad3a740
4
+ data.tar.gz: 846346e6d27381ea63fa1cb82b9044bef0acc25e5ca38ebf332fb692c02b577e
5
5
  SHA512:
6
- metadata.gz: c2cb942f81bfb49f952f955b9eea415b86718c8fe4fb3426cf7cdc1fcb8925b97dfe20e07300480fef894b6438ab5b2046d670564f8bc66a8a80bd5f14e2a282
7
- data.tar.gz: f5343edaa0fb6543d2f37e6017732969e63e687c9db877ff8ba188444244e1a180b59cedfbbef12d021a702f65cb089fc08f0ec79ab2363b660343d9df421f5c
6
+ metadata.gz: 49cda221593c2902534457cd4b89e7c24fedc3ee33f52ae4af5e83df14e4baf0cd68e547ec95c54e4dffd2fa1aea2989e51f371f5abfb37cc0630e7e4176977f
7
+ data.tar.gz: '059a4b9b2f260ab7dc8cb3f069120c74b3f76ec4ce5d6ff22f7eb625b75c6c032bfca3709795e247f82873d022a2f1e09ef939687e7c0f74589564cae41c9c94'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## `2.0.27`
2
+
3
+ * Add support for Renewal Information (ARI) (RFC 9773)
4
+
5
+ ## `2.0.26`
6
+
7
+ * Add support for dns-account-01 challenge (RFC draft-ietf-acme-dns-account-label-01)
8
+
1
9
  ## `2.0.25`
2
10
 
3
11
  * Add support for profiles extension
data/README.md CHANGED
@@ -120,9 +120,11 @@ To order a new certificate, the client must provide a list of identifiers.
120
120
 
121
121
  The returned order will contain a list of `Authorization` that need to be completed in other to finalize the order, generally one per identifier.
122
122
 
123
- Each authorization contains multiple challenges, typically a `dns-01` and a `http-01` challenge. The applicant is only required to complete one of the challenges.
123
+ Each authorization contains multiple challenges, typically a `dns-01`, `dns-account-01`, and a `http-01` challenge. The applicant is only required to complete one of the challenges.
124
124
 
125
- You can access the challenge you wish to complete using the `#dns` or `#http` method.
125
+ The `dns-account-01` challenge prepends an account-specific label before `_acme-challenge`, producing a record name of the form `_<label>._acme-challenge` so different clients can validate the same domain concurrently.
126
+
127
+ You can access the challenge you wish to complete using the `#dns`, `#dns_account`, or `#http` methods.
126
128
 
127
129
  ```ruby
128
130
  order = client.new_order(identifiers: ['example.com'])
@@ -165,6 +167,25 @@ dns_challenge.record_type # => 'TXT'
165
167
  dns_challenge.record_content # => 'HRV3PS5sRDyV-ous4HJk4z24s5JjmUTjcCaUjFt28-8'
166
168
  ```
167
169
 
170
+ ### Preparing for DNS-Account-01 challenge
171
+
172
+ To complete the DNS-Account-01 challenge, you must set a DNS TXT record using an account-specific name. This allows multiple ACME clients to validate the same domain concurrently without conflicts.
173
+
174
+ The DNSAccount01 object has utility methods to generate the required DNS record:
175
+
176
+ ```ruby
177
+ dns_account_challenge = authorization.dns_account
178
+
179
+ dns_account_challenge.record_name # => '_ujmmovf2vn55tgye._acme-challenge'
180
+ dns_account_challenge.record_type # => 'TXT'
181
+ dns_account_challenge.record_content # => 'HRV3PS5sRDyV-ous4HJk4z24s5JjmUTjcCaUjFt28-8'
182
+ ```
183
+
184
+ The record name includes an account-specific label derived from your account URL, ensuring different clients can validate simultaneously:
185
+
186
+ - **DNS-01**: `_acme-challenge.example.com` (shared)
187
+ - **DNS-Account-01**: `_ujmmovf2vn55tgye._acme-challenge.example.com` (account-specific)
188
+
168
189
  ### Requesting a challenge verification
169
190
 
170
191
  Once you are ready to complete the challenge, you can request the server perform the verification.
@@ -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,
@@ -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,
@@ -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,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.25'.freeze
5
+ VERSION = '2.0.27'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -135,12 +135,13 @@ class Acme::Client
135
135
  @kid ||= account.kid
136
136
  end
137
137
 
138
- def new_order(identifiers:, not_before: nil, not_after: nil, profile: nil)
138
+ def new_order(identifiers:, not_before: nil, not_after: nil, profile: nil, replaces: nil)
139
139
  payload = {}
140
140
  payload['identifiers'] = prepare_order_identifiers(identifiers)
141
141
  payload['notBefore'] = not_before if not_before
142
142
  payload['notAfter'] = not_after if not_after
143
143
  payload['profile'] = profile if profile
144
+ payload['replaces'] = replaces if replaces
144
145
 
145
146
  response = post(endpoint_for(:new_order), payload: payload)
146
147
  arguments = attributes_from_order_response(response)
@@ -223,6 +224,15 @@ class Acme::Client
223
224
  response.success?
224
225
  end
225
226
 
227
+ def renewal_info(certificate:)
228
+ cert_id = Acme::Client::Util.ari_certificate_identifier(certificate)
229
+ renewal_info_url = URI.join(endpoint_for(:renewal_info).to_s + '/', cert_id).to_s
230
+
231
+ response = get(renewal_info_url)
232
+ attributes = attributes_from_renewal_info_response(response)
233
+ Acme::Client::Resources::RenewalInfo.new(self, **attributes)
234
+ end
235
+
226
236
  def get_nonce
227
237
  http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce), options: @connection_options)
228
238
  response = http_client.head(nil, nil)
@@ -320,6 +330,16 @@ class Acme::Client
320
330
  extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
321
331
  end
322
332
 
333
+ def attributes_from_renewal_info_response(response)
334
+ attributes = extract_attributes(
335
+ response.body,
336
+ [:suggested_window, 'suggestedWindow'],
337
+ [:explanation_url, 'explanationURL']
338
+ )
339
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
340
+ attributes
341
+ end
342
+
323
343
  def extract_attributes(input, *attributes)
324
344
  attributes
325
345
  .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.25
4
+ version: 2.0.27
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charles Barbier
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-07 00:00:00.000000000 Z
10
+ date: 2025-11-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -167,10 +167,12 @@ files:
167
167
  - lib/acme/client/resources/challenges.rb
168
168
  - lib/acme/client/resources/challenges/base.rb
169
169
  - lib/acme/client/resources/challenges/dns01.rb
170
+ - lib/acme/client/resources/challenges/dns_account01.rb
170
171
  - lib/acme/client/resources/challenges/http01.rb
171
172
  - lib/acme/client/resources/challenges/unsupported_challenge.rb
172
173
  - lib/acme/client/resources/directory.rb
173
174
  - lib/acme/client/resources/order.rb
175
+ - lib/acme/client/resources/renewal_info.rb
174
176
  - lib/acme/client/self_sign_certificate.rb
175
177
  - lib/acme/client/util.rb
176
178
  - lib/acme/client/version.rb