acme-client 2.0.0 → 2.0.18

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.
@@ -5,7 +5,7 @@ class Acme::Client::Resources::Account
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
8
- assign_attributes(arguments)
8
+ assign_attributes(**arguments)
9
9
  end
10
10
 
11
11
  def kid
@@ -13,19 +13,19 @@ class Acme::Client::Resources::Account
13
13
  end
14
14
 
15
15
  def update(contact: nil, terms_of_service_agreed: nil)
16
- assign_attributes **@client.account_update(
16
+ assign_attributes(**@client.account_update(
17
17
  contact: contact, terms_of_service_agreed: term_of_service
18
- ).to_h
18
+ ).to_h)
19
19
  true
20
20
  end
21
21
 
22
22
  def deactivate
23
- assign_attributes **@client.account_deactivate.to_h
23
+ assign_attributes(**@client.account_deactivate.to_h)
24
24
  true
25
25
  end
26
26
 
27
27
  def reload
28
- assign_attributes **@client.account.to_h
28
+ assign_attributes(**@client.account.to_h)
29
29
  true
30
30
  end
31
31
 
@@ -5,16 +5,16 @@ class Acme::Client::Resources::Authorization
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
8
- assign_attributes(arguments)
8
+ assign_attributes(**arguments)
9
9
  end
10
10
 
11
11
  def deactivate
12
- assign_attributes **@client.deactivate_authorization(url: url).to_h
12
+ assign_attributes(**@client.deactivate_authorization(url: url).to_h)
13
13
  true
14
14
  end
15
15
 
16
16
  def reload
17
- assign_attributes **@client.authorization(url: url).to_h
17
+ assign_attributes(**@client.authorization(url: url).to_h)
18
18
  true
19
19
  end
20
20
 
@@ -56,7 +56,7 @@ class Acme::Client::Resources::Authorization
56
56
  type: attributes.fetch('type'),
57
57
  status: attributes.fetch('status'),
58
58
  url: attributes.fetch('url'),
59
- token: attributes.fetch('token'),
59
+ token: attributes.fetch('token', nil),
60
60
  error: attributes['error']
61
61
  }
62
62
  Acme::Client::Resources::Challenges.new(@client, **arguments)
@@ -5,7 +5,7 @@ class Acme::Client::Resources::Challenges::Base
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
8
- assign_attributes(arguments)
8
+ assign_attributes(**arguments)
9
9
  end
10
10
 
11
11
  def challenge_type
@@ -17,14 +17,14 @@ class Acme::Client::Resources::Challenges::Base
17
17
  end
18
18
 
19
19
  def reload
20
- assign_attributes **@client.challenge(url: url).to_h
20
+ assign_attributes(**@client.challenge(url: url).to_h)
21
21
  true
22
22
  end
23
23
 
24
24
  def request_validation
25
- assign_attributes **@client.request_challenge_validation(
26
- url: url, key_authorization: key_authorization
27
- ).to_h
25
+ assign_attributes(**send_challenge_validation(
26
+ url: url
27
+ ))
28
28
  true
29
29
  end
30
30
 
@@ -34,6 +34,12 @@ class Acme::Client::Resources::Challenges::Base
34
34
 
35
35
  private
36
36
 
37
+ def send_challenge_validation(url:)
38
+ @client.request_challenge_validation(
39
+ url: url
40
+ ).to_h
41
+ end
42
+
37
43
  def assign_attributes(status:, url:, token:, error: nil)
38
44
  @status = status
39
45
  @url = url
@@ -0,0 +1,2 @@
1
+ class Acme::Client::Resources::Challenges::Unsupported < Acme::Client::Resources::Challenges::Base
2
+ end
@@ -4,6 +4,7 @@ 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/unsupported_challenge'
7
8
 
8
9
  CHALLENGE_TYPES = {
9
10
  'http-01' => Acme::Client::Resources::Challenges::HTTP01,
@@ -11,11 +12,6 @@ module Acme::Client::Resources::Challenges
11
12
  }
12
13
 
13
14
  def self.new(client, type:, **arguments)
14
- klass = CHALLENGE_TYPES[type]
15
- if klass
16
- klass.new(client, **arguments)
17
- else
18
- { type: type }.merge(arguments)
19
- end
15
+ CHALLENGE_TYPES.fetch(type, Unsupported).new(client, **arguments)
20
16
  end
21
17
  end
@@ -17,12 +17,13 @@ class Acme::Client::Resources::Directory
17
17
  external_account_required: 'externalAccountRequired'
18
18
  }
19
19
 
20
- def initialize(url, connection_options)
21
- @url, @connection_options = url, connection_options
20
+ def initialize(client, **arguments)
21
+ @client = client
22
+ assign_attributes(**arguments)
22
23
  end
23
24
 
24
25
  def endpoint_for(key)
25
- directory.fetch(key) do |missing_key|
26
+ @directory.fetch(key) do |missing_key|
26
27
  raise Acme::Client::Error::UnsupportedOperation,
27
28
  "Directory at #{@url} does not include `#{missing_key}`"
28
29
  end
@@ -45,31 +46,16 @@ class Acme::Client::Resources::Directory
45
46
  end
46
47
 
47
48
  def meta
48
- directory[:meta]
49
+ @directory[:meta]
49
50
  end
50
51
 
51
52
  private
52
53
 
53
- def directory
54
- @directory ||= load_directory
55
- end
56
-
57
- def load_directory
58
- body = fetch_directory
59
- result = {}
60
- result[:meta] = body.delete('meta')
54
+ def assign_attributes(directory:)
55
+ @directory = {}
56
+ @directory[:meta] = directory.delete('meta')
61
57
  DIRECTORY_RESOURCES.each do |key, entry|
62
- result[key] = URI(body[entry]) if body[entry]
58
+ @directory[key] = URI(directory[entry]) if directory[entry]
63
59
  end
64
- result
65
- rescue JSON::ParserError => exception
66
- raise InvalidDirectory, "Invalid directory url\n#{@directory} did not return a valid directory\n#{exception.inspect}"
67
- end
68
-
69
- def fetch_directory
70
- connection = Faraday.new(url: @directory, **@connection_options)
71
- connection.headers[:user_agent] = Acme::Client::USER_AGENT
72
- response = connection.get(@url)
73
- JSON.parse(response.body)
74
60
  end
75
61
  end
@@ -5,11 +5,11 @@ class Acme::Client::Resources::Order
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
8
- assign_attributes(arguments)
8
+ assign_attributes(**arguments)
9
9
  end
10
10
 
11
11
  def reload
12
- assign_attributes **@client.order(url: url).to_h
12
+ assign_attributes(**@client.order(url: url).to_h)
13
13
  true
14
14
  end
15
15
 
@@ -20,13 +20,13 @@ class Acme::Client::Resources::Order
20
20
  end
21
21
 
22
22
  def finalize(csr:)
23
- assign_attributes **@client.finalize(url: finalize_url, csr: csr).to_h
23
+ assign_attributes(**@client.finalize(url: finalize_url, csr: csr).to_h)
24
24
  true
25
25
  end
26
26
 
27
- def certificate
27
+ def certificate(force_chain: nil)
28
28
  if certificate_url
29
- @client.certificate(url: certificate_url)
29
+ @client.certificate(url: certificate_url, force_chain: force_chain)
30
30
  else
31
31
  raise Acme::Client::Error::CertificateNotReady, 'No certificate_url to collect the order'
32
32
  end
@@ -1,8 +1,21 @@
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
5
7
 
8
+ LINK_MATCH = /<(.*?)>\s?;\s?rel="([\w-]+)"/
9
+
10
+ # See RFC 8288 - https://tools.ietf.org/html/rfc8288#section-3
11
+ def decode_link_headers(link_header)
12
+ link_header.split(',').each_with_object({}) { |entry, hash|
13
+ _, link, name = *entry.match(LINK_MATCH)
14
+ hash[name] ||= []
15
+ hash[name].push(link)
16
+ }
17
+ end
18
+
6
19
  # Sets public key on CSR or cert.
7
20
  #
8
21
  # obj - An OpenSSL::X509::Certificate or OpenSSL::X509::Request instance.
@@ -19,6 +32,4 @@ module Acme::Client::Util
19
32
  raise ArgumentError, 'priv must be EC or RSA'
20
33
  end
21
34
  end
22
-
23
- extend self
24
35
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.0'.freeze
5
+ VERSION = '2.0.18'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -1,24 +1,27 @@
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'
7
8
  require 'forwardable'
8
9
  require 'base64'
9
10
  require 'time'
11
+ require 'uri'
10
12
 
11
13
  module Acme; end
12
14
  class Acme::Client; end
13
15
 
14
16
  require 'acme/client/version'
17
+ require 'acme/client/http_client'
15
18
  require 'acme/client/certificate_request'
16
19
  require 'acme/client/self_sign_certificate'
17
20
  require 'acme/client/resources'
18
- require 'acme/client/faraday_middleware'
19
21
  require 'acme/client/jwk'
20
22
  require 'acme/client/error'
21
23
  require 'acme/client/util'
24
+ require 'acme/client/chain_identifier'
22
25
 
23
26
  class Acme::Client
24
27
  DEFAULT_DIRECTORY = 'http://127.0.0.1:4000/directory'.freeze
@@ -28,7 +31,7 @@ class Acme::Client
28
31
  pem: 'application/pem-certificate-chain'
29
32
  }
30
33
 
31
- def initialize(jwk: nil, kid: nil, private_key: nil, directory: DEFAULT_DIRECTORY, connection_options: {})
34
+ def initialize(jwk: nil, kid: nil, private_key: nil, directory: DEFAULT_DIRECTORY, connection_options: {}, bad_nonce_retry: 0)
32
35
  if jwk.nil? && private_key.nil?
33
36
  raise ArgumentError, 'must specify jwk or private_key'
34
37
  end
@@ -40,13 +43,15 @@ class Acme::Client
40
43
  end
41
44
 
42
45
  @kid, @connection_options = kid, connection_options
43
- @directory = Acme::Client::Resources::Directory.new(URI(directory), @connection_options)
46
+ @bad_nonce_retry = bad_nonce_retry
47
+ @directory_url = URI(directory)
44
48
  @nonces ||= []
45
49
  end
46
50
 
47
51
  attr_reader :jwk, :nonces
48
52
 
49
- 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)
50
55
  payload = {
51
56
  contact: Array(contact)
52
57
  }
@@ -55,7 +60,18 @@ class Acme::Client
55
60
  payload[:termsOfServiceAgreed] = terms_of_service_agreed
56
61
  end
57
62
 
58
- 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)
59
75
  @kid = response.headers.fetch(:location)
60
76
 
61
77
  if response.body.nil? || response.body.empty?
@@ -82,13 +98,35 @@ class Acme::Client
82
98
  Acme::Client::Resources::Account.new(self, url: kid, **arguments)
83
99
  end
84
100
 
101
+ def account_key_change(new_private_key: nil, new_jwk: nil)
102
+ if new_private_key.nil? && new_jwk.nil?
103
+ raise ArgumentError, 'must specify new_jwk or new_private_key'
104
+ end
105
+ old_jwk = jwk
106
+ new_jwk ||= Acme::Client::JWK.from_private_key(new_private_key)
107
+
108
+ inner_payload_header = {
109
+ url: endpoint_for(:key_change)
110
+ }
111
+ inner_payload = {
112
+ account: kid,
113
+ oldKey: old_jwk.to_h
114
+ }
115
+ payload = JSON.parse(new_jwk.jws(header: inner_payload_header, payload: inner_payload))
116
+
117
+ response = post(endpoint_for(:key_change), payload: payload, mode: :kid)
118
+ arguments = attributes_from_account_response(response)
119
+ @jwk = new_jwk
120
+ Acme::Client::Resources::Account.new(self, url: kid, **arguments)
121
+ end
122
+
85
123
  def account
86
124
  @kid ||= begin
87
125
  response = post(endpoint_for(:new_account), payload: { onlyReturnExisting: true }, mode: :jwk)
88
126
  response.headers.fetch(:location)
89
127
  end
90
128
 
91
- response = post(@kid)
129
+ response = post_as_get(@kid)
92
130
  arguments = attributes_from_account_response(response)
93
131
  Acme::Client::Resources::Account.new(self, url: @kid, **arguments)
94
132
  end
@@ -99,13 +137,7 @@ class Acme::Client
99
137
 
100
138
  def new_order(identifiers:, not_before: nil, not_after: nil)
101
139
  payload = {}
102
- payload['identifiers'] = if identifiers.is_a?(Hash)
103
- identifiers
104
- else
105
- Array(identifiers).map do |identifier|
106
- { type: 'dns', value: identifier }
107
- end
108
- end
140
+ payload['identifiers'] = prepare_order_identifiers(identifiers)
109
141
  payload['notBefore'] = not_before if not_before
110
142
  payload['notAfter'] = not_after if not_after
111
143
 
@@ -115,7 +147,7 @@ class Acme::Client
115
147
  end
116
148
 
117
149
  def order(url:)
118
- response = get(url)
150
+ response = post_as_get(url)
119
151
  arguments = attributes_from_order_response(response)
120
152
  Acme::Client::Resources::Order.new(self, **arguments.merge(url: url))
121
153
  end
@@ -131,13 +163,28 @@ class Acme::Client
131
163
  Acme::Client::Resources::Order.new(self, **arguments)
132
164
  end
133
165
 
134
- def certificate(url:)
166
+ def certificate(url:, force_chain: nil)
135
167
  response = download(url, format: :pem)
136
- response.body
168
+ pem = response.body
169
+
170
+ return pem if force_chain.nil?
171
+
172
+ return pem if ChainIdentifier.new(pem).match_name?(force_chain)
173
+
174
+ alternative_urls = Array(response.headers.dig('link', 'alternate'))
175
+ alternative_urls.each do |alternate_url|
176
+ response = download(alternate_url, format: :pem)
177
+ pem = response.body
178
+ if ChainIdentifier.new(pem).match_name?(force_chain)
179
+ return pem
180
+ end
181
+ end
182
+
183
+ raise Acme::Client::Error::ForcedChainNotFound, "Could not find any matching chain for `#{force_chain}`"
137
184
  end
138
185
 
139
186
  def authorization(url:)
140
- response = get(url)
187
+ response = post_as_get(url)
141
188
  arguments = attributes_from_authorization_response(response)
142
189
  Acme::Client::Resources::Authorization.new(self, url: url, **arguments)
143
190
  end
@@ -149,13 +196,13 @@ class Acme::Client
149
196
  end
150
197
 
151
198
  def challenge(url:)
152
- response = get(url)
199
+ response = post_as_get(url)
153
200
  arguments = attributes_from_challenge_response(response)
154
201
  Acme::Client::Resources::Challenges.new(self, **arguments)
155
202
  end
156
203
 
157
- def request_challenge_validation(url:, key_authorization:)
158
- response = post(url, payload: { keyAuthorization: key_authorization })
204
+ def request_challenge_validation(url:, key_authorization: nil)
205
+ response = post(url, payload: {})
159
206
  arguments = attributes_from_challenge_response(response)
160
207
  Acme::Client::Resources::Challenges.new(self, **arguments)
161
208
  end
@@ -176,33 +223,64 @@ class Acme::Client
176
223
  end
177
224
 
178
225
  def get_nonce
179
- response = Faraday.head(endpoint_for(:new_nonce), nil, 'User-Agent' => USER_AGENT)
226
+ http_client = Acme::Client::HTTPClient.new_connection(url: endpoint_for(:new_nonce), options: @connection_options)
227
+ response = http_client.head(nil, nil)
180
228
  nonces << response.headers['replay-nonce']
181
229
  true
182
230
  end
183
231
 
232
+ def directory
233
+ @directory ||= load_directory
234
+ end
235
+
184
236
  def meta
185
- @directory.meta
237
+ directory.meta
186
238
  end
187
239
 
188
240
  def terms_of_service
189
- @directory.terms_of_service
241
+ directory.terms_of_service
190
242
  end
191
243
 
192
244
  def website
193
- @directory.website
245
+ directory.website
194
246
  end
195
247
 
196
248
  def caa_identities
197
- @directory.caa_identities
249
+ directory.caa_identities
198
250
  end
199
251
 
200
252
  def external_account_required
201
- @directory.external_account_required
253
+ directory.external_account_required
202
254
  end
203
255
 
204
256
  private
205
257
 
258
+ def load_directory
259
+ Acme::Client::Resources::Directory.new(self, directory: fetch_directory)
260
+ end
261
+
262
+ def fetch_directory
263
+ response = get(@directory_url)
264
+ response.body
265
+ rescue JSON::ParserError => exception
266
+ raise Acme::Client::Error::InvalidDirectory,
267
+ "Invalid directory url\n#{@directory_url} did not return a valid directory\n#{exception.inspect}"
268
+ end
269
+
270
+ def prepare_order_identifiers(identifiers)
271
+ if identifiers.is_a?(Hash)
272
+ [identifiers]
273
+ else
274
+ Array(identifiers).map do |identifier|
275
+ if identifier.is_a?(String)
276
+ { type: 'dns', value: identifier }
277
+ else
278
+ identifier
279
+ end
280
+ end
281
+ end
282
+ end
283
+
206
284
  def attributes_from_account_response(response)
207
285
  extract_attributes(
208
286
  response.body,
@@ -249,14 +327,19 @@ class Acme::Client
249
327
  connection.post(url, payload)
250
328
  end
251
329
 
252
- def get(url, mode: :kid)
330
+ def post_as_get(url, mode: :kid)
331
+ connection = connection_for(url: url, mode: mode)
332
+ connection.post(url, nil)
333
+ end
334
+
335
+ def get(url, mode: :get)
253
336
  connection = connection_for(url: url, mode: mode)
254
337
  connection.get(url)
255
338
  end
256
339
 
257
340
  def download(url, format:)
258
- connection = connection_for(url: url, mode: :download)
259
- connection.get do |request|
341
+ connection = connection_for(url: url, mode: :kid)
342
+ connection.post do |request|
260
343
  request.url(url)
261
344
  request.headers['Accept'] = CONTENT_TYPES.fetch(format)
262
345
  end
@@ -265,29 +348,15 @@ class Acme::Client
265
348
  def connection_for(url:, mode:)
266
349
  uri = URI(url)
267
350
  endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}"
351
+
268
352
  @connections ||= {}
269
353
  @connections[mode] ||= {}
270
- @connections[mode][endpoint] ||= new_connection(endpoint: endpoint, mode: mode)
271
- end
272
-
273
- def new_connection(endpoint:, mode:)
274
- Faraday.new(endpoint, **@connection_options) do |configuration|
275
- configuration.use Acme::Client::FaradayMiddleware, client: self, mode: mode
276
- configuration.adapter Faraday.default_adapter
277
- end
278
- end
279
-
280
- def fetch_chain(response, limit = 10)
281
- links = response.headers['link']
282
- if limit.zero? || links.nil? || links['up'].nil?
283
- []
284
- else
285
- issuer = get(links['up'])
286
- [OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)]
287
- end
354
+ @connections[mode][endpoint] ||= Acme::Client::HTTPClient.new_acme_connection(
355
+ url: URI(endpoint), mode: mode, client: self, options: @connection_options, bad_nonce_retry: @bad_nonce_retry
356
+ )
288
357
  end
289
358
 
290
359
  def endpoint_for(key)
291
- @directory.endpoint_for(key)
360
+ directory.endpoint_for(key)
292
361
  end
293
362
  end