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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +23 -2
- data/lib/acme/client/error.rb +2 -0
- data/lib/acme/client/resources/authorization.rb +7 -0
- data/lib/acme/client/resources/challenges/dns_account01.rb +30 -0
- data/lib/acme/client/resources/challenges.rb +3 -1
- data/lib/acme/client/resources/directory.rb +2 -1
- data/lib/acme/client/resources/renewal_info.rb +47 -0
- data/lib/acme/client/resources.rb +1 -0
- data/lib/acme/client/util.rb +37 -0
- data/lib/acme/client/version.rb +1 -1
- data/lib/acme/client.rb +21 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d41b8df6f16f62b2bd33a0fd960d7675c02e1d065fb3bf3466b58f59aad3a740
|
|
4
|
+
data.tar.gz: 846346e6d27381ea63fa1cb82b9044bef0acc25e5ca38ebf332fb692c02b577e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49cda221593c2902534457cd4b89e7c24fedc3ee33f52ae4af5e83df14e4baf0cd68e547ec95c54e4dffd2fa1aea2989e51f371f5abfb37cc0630e7e4176977f
|
|
7
|
+
data.tar.gz: '059a4b9b2f260ab7dc8cb3f069120c74b3f76ec4ce5d6ff22f7eb625b75c6c032bfca3709795e247f82873d022a2f1e09ef939687e7c0f74589564cae41c9c94'
|
data/CHANGELOG.md
CHANGED
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
|
-
|
|
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.
|
data/lib/acme/client/error.rb
CHANGED
|
@@ -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)
|
|
@@ -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
|
data/lib/acme/client/util.rb
CHANGED
|
@@ -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
|
data/lib/acme/client/version.rb
CHANGED
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.
|
|
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-
|
|
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
|