acme-client 2.0.11 → 2.0.14

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: 3ff7c41acdce5ad9f39a371998b37466d8affe4cb3de5e856d44d36e512180ea
4
- data.tar.gz: cb9fda5e72bf22dc93659a238b05dd419652584238a024658ebde04aaefc9e80
3
+ metadata.gz: 6238349e171138af08de444bf08268ab3bebab0c4d0497bee91d9d4a30d397ab
4
+ data.tar.gz: 9928049d9997c284cec6d75e12bf6b5f07150459775d8f852adbc09b7c923178
5
5
  SHA512:
6
- metadata.gz: eea011baa47710043bab4f22e6556d6ece49ae1a87005e30ec25a41d1abeb93bf6a049ecadf742d706c6ebef7c6ece862704aadd96c252e8c6d801bf814c221a
7
- data.tar.gz: 77a75476bd154d46349acee637aad44e08ef911a676e0da5de9267b5f1cc1b845cd2d8030511fd25e2ee31354cdb269f4151a6c1ae36816ff75908864effa6d6
6
+ metadata.gz: 8543f742ee8fe3822c8c989a7e5f8b1f75473f01a36d7a2218c44b43ebec55fa538beabc4545c4798a7b35005ad1fddbd0ed59dbae6942413812fae9ce625f92
7
+ data.tar.gz: 77cd41773aa293bcb65ebdb9489c500a06b144e0efb2dc5af23baa8dec8c36c1af32fd9f1c97eb77e40eb4a521a9c904448d5765f64dcbe56a67f4907225aaf3
@@ -11,8 +11,8 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  strategy:
13
13
  matrix:
14
- ruby-version: ['2.6', '2.7', '3.0', truffleruby-head]
15
- faraday-version: ['~> 1.7', '~> 2.0']
14
+ ruby-version: ['2.7', '3.0', '3.1', '3.2']
15
+ faraday-version: ['~> 1.10', '~> 2.7']
16
16
  env:
17
17
  FARADAY_VERSION: ${{ matrix.faraday-version }}
18
18
  steps:
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
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`
10
+
11
+ * Update test matrix to current Ruby versions (2.7 to 3.2)
12
+ * Support for Faraday retry 2.x
13
+
1
14
  ## `2.0.11`
2
15
 
3
16
  * Add support for error code `AlreadyRevoked` and `BadPublicKey`
data/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # Acme::Client
2
2
 
3
- [![Build Status](https://travis-ci.org/unixcharles/acme-client.svg?branch=master)](https://travis-ci.org/unixcharles/acme-client)
4
-
5
3
  `acme-client` is a client implementation of the ACMEv2 / [RFC 8555](https://tools.ietf.org/html/rfc8555) protocol in Ruby.
6
4
 
7
5
  You can find the ACME reference implementations of the [server](https://github.com/letsencrypt/boulder) in Go and the [client](https://github.com/certbot/certbot) in Python.
@@ -108,6 +106,15 @@ client.kid
108
106
  => "https://acme-staging-v02.api.letsencrypt.org/acme/acct/000000"
109
107
  ```
110
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
+
111
118
  ## Obtaining a certificate
112
119
  ### Ordering a certificate
113
120
 
@@ -237,7 +244,7 @@ To change the key used for an account you can call `#account_key_change` with th
237
244
  ```ruby
238
245
  require 'openssl'
239
246
  new_private_key = OpenSSL::PKey::RSA.new(4096)
240
- client.account_key_change(private_key: new_private_key)
247
+ client.account_key_change(new_private_key: new_private_key)
241
248
  ```
242
249
 
243
250
  ## Requirements
data/acme-client.gemspec CHANGED
@@ -24,5 +24,5 @@ Gem::Specification.new do |spec|
24
24
  spec.add_development_dependency 'webrick'
25
25
 
26
26
  spec.add_runtime_dependency 'faraday', '>= 1.0', '< 3.0.0'
27
- spec.add_runtime_dependency 'faraday-retry', '~> 1.0'
27
+ spec.add_runtime_dependency 'faraday-retry', '>= 1.0', '< 3.0.0'
28
28
  end
@@ -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.11'.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.11
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: 2022-06-01 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
@@ -118,17 +118,23 @@ dependencies:
118
118
  name: faraday-retry
119
119
  requirement: !ruby/object:Gem::Requirement
120
120
  requirements:
121
- - - "~>"
121
+ - - ">="
122
122
  - !ruby/object:Gem::Version
123
123
  version: '1.0'
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: 3.0.0
124
127
  type: :runtime
125
128
  prerelease: false
126
129
  version_requirements: !ruby/object:Gem::Requirement
127
130
  requirements:
128
- - - "~>"
131
+ - - ">="
129
132
  - !ruby/object:Gem::Version
130
133
  version: '1.0'
131
- description:
134
+ - - "<"
135
+ - !ruby/object:Gem::Version
136
+ version: 3.0.0
137
+ description:
132
138
  email:
133
139
  - unixcharles@gmail.com
134
140
  executables: []
@@ -155,10 +161,11 @@ files:
155
161
  - lib/acme/client/certificate_request/ec_key_patch.rb
156
162
  - lib/acme/client/chain_identifier.rb
157
163
  - lib/acme/client/error.rb
158
- - lib/acme/client/faraday_middleware.rb
164
+ - lib/acme/client/http_client.rb
159
165
  - lib/acme/client/jwk.rb
160
166
  - lib/acme/client/jwk/base.rb
161
167
  - lib/acme/client/jwk/ecdsa.rb
168
+ - lib/acme/client/jwk/hmac.rb
162
169
  - lib/acme/client/jwk/rsa.rb
163
170
  - lib/acme/client/resources.rb
164
171
  - lib/acme/client/resources/account.rb
@@ -177,7 +184,7 @@ homepage: http://github.com/unixcharles/acme-client
177
184
  licenses:
178
185
  - MIT
179
186
  metadata: {}
180
- post_install_message:
187
+ post_install_message:
181
188
  rdoc_options: []
182
189
  require_paths:
183
190
  - lib
@@ -192,8 +199,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
199
  - !ruby/object:Gem::Version
193
200
  version: '0'
194
201
  requirements: []
195
- rubygems_version: 3.2.20
196
- signing_key:
202
+ rubygems_version: 3.0.3.1
203
+ signing_key:
197
204
  specification_version: 4
198
205
  summary: Client for the ACME protocol.
199
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