acme-client 2.0.12 → 2.0.14

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 409e7fad9385308a4e8127d743897dd27683dfd8c711ea7e252624b0ea5599b5
4
- data.tar.gz: 0df61205e809a7a2075cc26618023a5958b4820391408f217f7b34a79eb51391
3
+ metadata.gz: 6238349e171138af08de444bf08268ab3bebab0c4d0497bee91d9d4a30d397ab
4
+ data.tar.gz: 9928049d9997c284cec6d75e12bf6b5f07150459775d8f852adbc09b7c923178
5
5
  SHA512:
6
- metadata.gz: 90caf54c02324bfd5a9e2f41a293956d2fa940208a8cf26aafc26aa19fbd0810e21284c73b408b5a03b97ee41476fa1e424199cf89d07dfe79460f878b118392
7
- data.tar.gz: 9d8465df2b32462b17aed637db02a86abdd84d8236c68bd46525aa34473f2583ebd8562a7fb94c4dcca498427a7e0f0de249f30a72162bea661cca7abbd91557
6
+ metadata.gz: 8543f742ee8fe3822c8c989a7e5f8b1f75473f01a36d7a2218c44b43ebec55fa538beabc4545c4798a7b35005ad1fddbd0ed59dbae6942413812fae9ce625f92
7
+ data.tar.gz: 77cd41773aa293bcb65ebdb9489c500a06b144e0efb2dc5af23baa8dec8c36c1af32fd9f1c97eb77e40eb4a521a9c904448d5765f64dcbe56a67f4907225aaf3
data/CHANGELOG.md CHANGED
@@ -1,4 +1,12 @@
1
- ## `2.0.11`
1
+ ## `2.0.14`
2
+
3
+ * Fix Faraday HTTP exceptions leaking out, always raise `Acme::Client::Error` instead
4
+
5
+ ## `2.0.13`
6
+
7
+ * Add support for External Account Binding
8
+
9
+ ## `2.0.12`
2
10
 
3
11
  * Update test matrix to current Ruby versions (2.7 to 3.2)
4
12
  * Support for Faraday retry 2.x
data/README.md CHANGED
@@ -106,6 +106,15 @@ client.kid
106
106
  => "https://acme-staging-v02.api.letsencrypt.org/acme/acct/000000"
107
107
  ```
108
108
 
109
+ ## External Account Binding support
110
+
111
+ You can use External Account Binding by providing a `external_account_binding` with a `kid` and `hmac_key`.
112
+
113
+ ```ruby
114
+ client = Acme::Client.new(private_key: private_key, directory: 'https://acme.zerossl.com/v2/DV90')
115
+ account = client.new_account(contact: 'mailto:info@example.com', terms_of_service_agreed: true, external_account_binding: { kid: "your kid", hmac_key: "your hmac key"})
116
+ ```
117
+
109
118
  ## Obtaining a certificate
110
119
  ### Ordering a certificate
111
120
 
@@ -235,7 +244,7 @@ To change the key used for an account you can call `#account_key_change` with th
235
244
  ```ruby
236
245
  require 'openssl'
237
246
  new_private_key = OpenSSL::PKey::RSA.new(4096)
238
- client.account_key_change(private_key: new_private_key)
247
+ client.account_key_change(new_private_key: new_private_key)
239
248
  ```
240
249
 
241
250
  ## Requirements
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acme::Client::HTTPClient
4
+ # Creates and returns a new HTTP client, with default settings.
5
+ #
6
+ # @param url [URI:HTTPS]
7
+ # @param options [Hash]
8
+ # @return [Faraday::Connection]
9
+ def self.new_connection(url:, options: {})
10
+ Faraday.new(url, options) do |configuration|
11
+ configuration.use Acme::Client::HTTPClient::ErrorMiddleware
12
+
13
+ yield(configuration) if block_given?
14
+
15
+ configuration.headers[:user_agent] = Acme::Client::USER_AGENT
16
+ configuration.adapter Faraday.default_adapter
17
+ end
18
+ end
19
+
20
+ # Creates and returns a new HTTP client designed for the Acme-protocol, with default settings.
21
+ #
22
+ # @param url [URI:HTTPS]
23
+ # @param client [Acme::Client]
24
+ # @param mode [Symbol]
25
+ # @param options [Hash]
26
+ # @param bad_nonce_retry [Integer]
27
+ # @return [Faraday::Connection]
28
+ def self.new_acme_connection(url:, client:, mode:, options: {}, bad_nonce_retry: 0)
29
+ new_connection(url: url, options: options) do |configuration|
30
+ if bad_nonce_retry > 0
31
+ configuration.request(:retry,
32
+ max: bad_nonce_retry,
33
+ methods: Faraday::Connection::METHODS,
34
+ exceptions: [Acme::Client::Error::BadNonce])
35
+ end
36
+
37
+ configuration.use Acme::Client::HTTPClient::AcmeMiddleware, client: client, mode: mode
38
+
39
+ yield(configuration) if block_given?
40
+ end
41
+ end
42
+
43
+ # ErrorMiddleware ensures the HTTP Client would not raise exceptions outside the Acme namespace.
44
+ #
45
+ # Exceptions are rescued and re-packaged as Acme exceptions.
46
+ class ErrorMiddleware < Faraday::Middleware
47
+ # Implements the Rack-alike Faraday::Middleware interface.
48
+ def call(env)
49
+ @app.call(env)
50
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed
51
+ raise Acme::Client::Error::Timeout
52
+ end
53
+ end
54
+
55
+ # AcmeMiddleware implements the Acme-protocol requirements for JWK requests.
56
+ class AcmeMiddleware < Faraday::Middleware
57
+ attr_reader :env, :response, :client
58
+
59
+ CONTENT_TYPE = 'application/jose+json'
60
+
61
+ def initialize(app, options)
62
+ super(app)
63
+ @client = options.fetch(:client)
64
+ @mode = options.fetch(:mode)
65
+ end
66
+
67
+ def call(env)
68
+ @env = env
69
+ @env[:request_headers]['Content-Type'] = CONTENT_TYPE
70
+
71
+ if @env.method != :get
72
+ @env.body = client.jwk.jws(header: jws_header, payload: env.body)
73
+ end
74
+
75
+ @app.call(env).on_complete { |response_env| on_complete(response_env) }
76
+ end
77
+
78
+ def on_complete(env)
79
+ @env = env
80
+
81
+ raise_on_not_found!
82
+ store_nonce
83
+ env.body = decode_body
84
+ env.response_headers['Link'] = decode_link_headers
85
+
86
+ return if env.success?
87
+
88
+ raise_on_error!
89
+ end
90
+
91
+ private
92
+
93
+ def jws_header
94
+ headers = { nonce: pop_nonce, url: env.url.to_s }
95
+ headers[:kid] = client.kid if @mode == :kid
96
+ headers
97
+ end
98
+
99
+ def raise_on_not_found!
100
+ raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404
101
+ end
102
+
103
+ def raise_on_error!
104
+ raise error_class, error_message
105
+ end
106
+
107
+ def error_message
108
+ if env.body.is_a? Hash
109
+ env.body['detail']
110
+ else
111
+ "Error message: #{env.body}"
112
+ end
113
+ end
114
+
115
+ def error_class
116
+ Acme::Client::Error::ACME_ERRORS.fetch(error_name, Acme::Client::Error)
117
+ end
118
+
119
+ def error_name
120
+ return unless env.body.is_a?(Hash)
121
+ return unless env.body.key?('type')
122
+ env.body['type']
123
+ end
124
+
125
+ def decode_body
126
+ content_type = env.response_headers['Content-Type'].to_s
127
+
128
+ if content_type.start_with?('application/json', 'application/problem+json')
129
+ JSON.load(env.body)
130
+ else
131
+ env.body
132
+ end
133
+ end
134
+
135
+ def decode_link_headers
136
+ return unless env.response_headers.key?('Link')
137
+ link_header = env.response_headers['Link']
138
+ Acme::Client::Util.decode_link_headers(link_header)
139
+ end
140
+
141
+ def store_nonce
142
+ nonce = env.response_headers['replay-nonce']
143
+ nonces << nonce if nonce
144
+ end
145
+
146
+ def pop_nonce
147
+ if nonces.empty?
148
+ get_nonce
149
+ end
150
+
151
+ nonces.pop
152
+ end
153
+
154
+ def get_nonce
155
+ client.get_nonce
156
+ end
157
+
158
+ def nonces
159
+ client.nonces
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Acme::Client::JWK::HMAC < Acme::Client::JWK::Base
4
+ # Instantiate a new HMAC JWS.
5
+ #
6
+ # key - A string.
7
+ #
8
+ # Returns nothing.
9
+ def initialize(key)
10
+ @key = key
11
+ end
12
+
13
+ # Sign a message with the private key.
14
+ #
15
+ # message - A String message to sign.
16
+ #
17
+ # Returns a String signature.
18
+ def sign(message)
19
+ OpenSSL::HMAC.digest('SHA256', @key, message)
20
+ end
21
+
22
+ # The name of the algorithm as needed for the `alg` member of a JWS object.
23
+ #
24
+ # Returns a String.
25
+ def jwa_alg
26
+ # https://tools.ietf.org/html/rfc7518#section-3.1
27
+ # HMAC using SHA-256
28
+ 'HS256'
29
+ end
30
+ end
@@ -19,3 +19,4 @@ end
19
19
  require 'acme/client/jwk/base'
20
20
  require 'acme/client/jwk/rsa'
21
21
  require 'acme/client/jwk/ecdsa'
22
+ require 'acme/client/jwk/hmac'
@@ -68,13 +68,8 @@ class Acme::Client::Resources::Directory
68
68
  end
69
69
 
70
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
75
- end
76
- connection.headers[:user_agent] = Acme::Client::USER_AGENT
77
- response = connection.get(@url)
71
+ http_client = Acme::Client::HTTPClient.new_acme_connection(url: @directory, options: @connection_options, client: nil, mode: nil)
72
+ response = http_client.get(@url)
78
73
  response.body
79
74
  end
80
75
  end
@@ -1,4 +1,6 @@
1
1
  module Acme::Client::Util
2
+ extend self
3
+
2
4
  def urlsafe_base64(data)
3
5
  Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
4
6
  end
@@ -30,6 +32,4 @@ module Acme::Client::Util
30
32
  raise ArgumentError, 'priv must be EC or RSA'
31
33
  end
32
34
  end
33
-
34
- extend self
35
35
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.12'.freeze
5
+ VERSION = '2.0.14'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -14,10 +14,10 @@ module Acme; end
14
14
  class Acme::Client; end
15
15
 
16
16
  require 'acme/client/version'
17
+ require 'acme/client/http_client'
17
18
  require 'acme/client/certificate_request'
18
19
  require 'acme/client/self_sign_certificate'
19
20
  require 'acme/client/resources'
20
- require 'acme/client/faraday_middleware'
21
21
  require 'acme/client/jwk'
22
22
  require 'acme/client/error'
23
23
  require 'acme/client/util'
@@ -50,7 +50,8 @@ class Acme::Client
50
50
 
51
51
  attr_reader :jwk, :nonces
52
52
 
53
- def new_account(contact:, terms_of_service_agreed: nil)
53
+ def new_account(contact:, terms_of_service_agreed: nil, external_account_binding: nil)
54
+ new_account_endpoint = endpoint_for(:new_account)
54
55
  payload = {
55
56
  contact: Array(contact)
56
57
  }
@@ -59,7 +60,18 @@ class Acme::Client
59
60
  payload[:termsOfServiceAgreed] = terms_of_service_agreed
60
61
  end
61
62
 
62
- response = post(endpoint_for(:new_account), payload: payload, mode: :jws)
63
+ if external_account_binding
64
+ kid, hmac_key = external_account_binding.values_at(:kid, :hmac_key)
65
+ if kid.nil? || hmac_key.nil?
66
+ raise ArgumentError, 'must specify kid and hmac_key key for external_account_binding'
67
+ end
68
+
69
+ hmac = Acme::Client::JWK::HMAC.new(Base64.urlsafe_decode64(hmac_key))
70
+ external_account_payload = hmac.jws(header: { kid: kid, url: new_account_endpoint }, payload: @jwk)
71
+ payload[:externalAccountBinding] = JSON.parse(external_account_payload)
72
+ end
73
+
74
+ response = post(new_account_endpoint, payload: payload, mode: :jws)
63
75
  @kid = response.headers.fetch(:location)
64
76
 
65
77
  if response.body.nil? || response.body.empty?
@@ -211,8 +223,8 @@ class Acme::Client
211
223
  end
212
224
 
213
225
  def get_nonce
214
- connection = new_connection(endpoint: endpoint_for(:new_nonce))
215
- response = connection.head(nil, nil, 'User-Agent' => USER_AGENT)
226
+ http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce))
227
+ response = http_client.head(nil, nil)
216
228
  nonces << response.headers['replay-nonce']
217
229
  true
218
230
  end
@@ -320,28 +332,12 @@ class Acme::Client
320
332
  def connection_for(url:, mode:)
321
333
  uri = URI(url)
322
334
  endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}"
335
+
323
336
  @connections ||= {}
324
337
  @connections[mode] ||= {}
325
- @connections[mode][endpoint] ||= new_acme_connection(endpoint: endpoint, mode: mode)
326
- end
327
-
328
- def new_acme_connection(endpoint:, mode:)
329
- new_connection(endpoint: endpoint) do |configuration|
330
- configuration.use Acme::Client::FaradayMiddleware, client: self, mode: mode
331
- end
332
- end
333
-
334
- def new_connection(endpoint:)
335
- Faraday.new(endpoint, **@connection_options) do |configuration|
336
- if @bad_nonce_retry > 0
337
- configuration.request(:retry,
338
- max: @bad_nonce_retry,
339
- methods: Faraday::Connection::METHODS,
340
- exceptions: [Acme::Client::Error::BadNonce])
341
- end
342
- yield(configuration) if block_given?
343
- configuration.adapter Faraday.default_adapter
344
- end
338
+ @connections[mode][endpoint] ||= Acme::Client::HTTPClient.new_acme_connection(
339
+ url: URI(endpoint), mode: mode, client: self, options: @connection_options, bad_nonce_retry: @bad_nonce_retry
340
+ )
345
341
  end
346
342
 
347
343
  def fetch_chain(response, limit = 10)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acme-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.12
4
+ version: 2.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charles Barbier
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-31 00:00:00.000000000 Z
11
+ date: 2023-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -134,7 +134,7 @@ dependencies:
134
134
  - - "<"
135
135
  - !ruby/object:Gem::Version
136
136
  version: 3.0.0
137
- description:
137
+ description:
138
138
  email:
139
139
  - unixcharles@gmail.com
140
140
  executables: []
@@ -161,10 +161,11 @@ files:
161
161
  - lib/acme/client/certificate_request/ec_key_patch.rb
162
162
  - lib/acme/client/chain_identifier.rb
163
163
  - lib/acme/client/error.rb
164
- - lib/acme/client/faraday_middleware.rb
164
+ - lib/acme/client/http_client.rb
165
165
  - lib/acme/client/jwk.rb
166
166
  - lib/acme/client/jwk/base.rb
167
167
  - lib/acme/client/jwk/ecdsa.rb
168
+ - lib/acme/client/jwk/hmac.rb
168
169
  - lib/acme/client/jwk/rsa.rb
169
170
  - lib/acme/client/resources.rb
170
171
  - lib/acme/client/resources/account.rb
@@ -183,7 +184,7 @@ homepage: http://github.com/unixcharles/acme-client
183
184
  licenses:
184
185
  - MIT
185
186
  metadata: {}
186
- post_install_message:
187
+ post_install_message:
187
188
  rdoc_options: []
188
189
  require_paths:
189
190
  - lib
@@ -198,8 +199,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
198
199
  - !ruby/object:Gem::Version
199
200
  version: '0'
200
201
  requirements: []
201
- rubygems_version: 3.4.1
202
- signing_key:
202
+ rubygems_version: 3.0.3.1
203
+ signing_key:
203
204
  specification_version: 4
204
205
  summary: Client for the ACME protocol.
205
206
  test_files: []
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Acme::Client::FaradayMiddleware < Faraday::Middleware
4
- attr_reader :env, :response, :client
5
-
6
- CONTENT_TYPE = 'application/jose+json'
7
-
8
- def initialize(app, options)
9
- super(app)
10
- @client = options.fetch(:client)
11
- @mode = options.fetch(:mode)
12
- end
13
-
14
- def call(env)
15
- @env = env
16
- @env[:request_headers]['User-Agent'] = Acme::Client::USER_AGENT
17
- @env[:request_headers]['Content-Type'] = CONTENT_TYPE
18
-
19
- if @env.method != :get
20
- @env.body = client.jwk.jws(header: jws_header, payload: env.body)
21
- end
22
-
23
- @app.call(env).on_complete { |response_env| on_complete(response_env) }
24
- rescue Faraday::TimeoutError, Faraday::ConnectionFailed
25
- raise Acme::Client::Error::Timeout
26
- end
27
-
28
- def on_complete(env)
29
- @env = env
30
-
31
- raise_on_not_found!
32
- store_nonce
33
- env.body = decode_body
34
- env.response_headers['Link'] = decode_link_headers
35
-
36
- return if env.success?
37
-
38
- raise_on_error!
39
- end
40
-
41
- private
42
-
43
- def jws_header
44
- headers = { nonce: pop_nonce, url: env.url.to_s }
45
- headers[:kid] = client.kid if @mode == :kid
46
- headers
47
- end
48
-
49
- def raise_on_not_found!
50
- raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404
51
- end
52
-
53
- def raise_on_error!
54
- raise error_class, error_message
55
- end
56
-
57
- def error_message
58
- if env.body.is_a? Hash
59
- env.body['detail']
60
- else
61
- "Error message: #{env.body}"
62
- end
63
- end
64
-
65
- def error_class
66
- Acme::Client::Error::ACME_ERRORS.fetch(error_name, Acme::Client::Error)
67
- end
68
-
69
- def error_name
70
- return unless env.body.is_a?(Hash)
71
- return unless env.body.key?('type')
72
- env.body['type']
73
- end
74
-
75
- def decode_body
76
- content_type = env.response_headers['Content-Type'].to_s
77
-
78
- if content_type.start_with?('application/json', 'application/problem+json')
79
- JSON.load(env.body)
80
- else
81
- env.body
82
- end
83
- end
84
-
85
- def decode_link_headers
86
- return unless env.response_headers.key?('Link')
87
- link_header = env.response_headers['Link']
88
- Acme::Client::Util.decode_link_headers(link_header)
89
- end
90
-
91
- def store_nonce
92
- nonce = env.response_headers['replay-nonce']
93
- nonces << nonce if nonce
94
- end
95
-
96
- def pop_nonce
97
- if nonces.empty?
98
- get_nonce
99
- end
100
-
101
- nonces.pop
102
- end
103
-
104
- def get_nonce
105
- client.get_nonce
106
- end
107
-
108
- def nonces
109
- client.nonces
110
- end
111
- end