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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/Gemfile +0 -5
- data/README.md +52 -11
- data/Rakefile +1 -4
- data/acme-client.gemspec +9 -11
- data/bin/generate_keystash +9 -0
- data/lib/acme/client/certificate_request.rb +19 -2
- data/lib/acme/client/error/rate_limited.rb +15 -0
- data/lib/acme/client/error.rb +41 -0
- data/lib/acme/client/http_client.rb +173 -0
- data/lib/acme/client/jwk/ecdsa.rb +4 -4
- data/lib/acme/client/jwk/hmac.rb +30 -0
- data/lib/acme/client/jwk.rb +1 -0
- data/lib/acme/client/resources/account.rb +4 -2
- data/lib/acme/client/resources/authorization.rb +14 -4
- data/lib/acme/client/resources/challenges/base.rb +15 -3
- 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 +17 -30
- data/lib/acme/client/resources/order.rb +25 -4
- data/lib/acme/client/resources/renewal_info.rb +54 -0
- data/lib/acme/client/resources.rb +1 -0
- data/lib/acme/client/util.rb +56 -1
- data/lib/acme/client/version.rb +1 -1
- data/lib/acme/client.rb +89 -47
- metadata +68 -38
- data/.github/workflows/rubocop.yml +0 -23
- data/.github/workflows/test.yml +0 -26
- data/.gitignore +0 -12
- data/.rspec +0 -3
- data/.rubocop.yml +0 -134
- data/lib/acme/client/faraday_middleware.rb +0 -111
|
@@ -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(
|
|
21
|
-
@
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/acme/client/util.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/acme/client/version.rb
CHANGED
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
response =
|
|
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
|
-
|
|
254
|
+
directory.meta
|
|
221
255
|
end
|
|
222
256
|
|
|
223
257
|
def terms_of_service
|
|
224
|
-
|
|
258
|
+
directory.terms_of_service
|
|
225
259
|
end
|
|
226
260
|
|
|
227
261
|
def website
|
|
228
|
-
|
|
262
|
+
directory.website
|
|
229
263
|
end
|
|
230
264
|
|
|
231
265
|
def caa_identities
|
|
232
|
-
|
|
266
|
+
directory.caa_identities
|
|
233
267
|
end
|
|
234
268
|
|
|
235
269
|
def external_account_required
|
|
236
|
-
|
|
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: :
|
|
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(
|
|
325
|
-
|
|
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
|
-
|
|
399
|
+
directory.endpoint_for(key)
|
|
358
400
|
end
|
|
359
401
|
end
|