acme-client 2.0.0 → 2.0.18

Sign up to get free protection for your applications and to get access to all the features.
@@ -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