acme-client 2.0.6 → 2.0.9

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: '09b859082bb56c35a6c050801def72343fe9f9737213155705f691bb044a54cc'
4
- data.tar.gz: be387d14e92a862fb79668579d456050cdf0e8e5c36de6726bbd80e073410c41
3
+ metadata.gz: d1fcaf1206f38a9ded860ad3685de94d3c2743a04222d63a6d5a14ca35146daa
4
+ data.tar.gz: 766a3bf59e3877a3a4ab457e369f9d09626204afacc8e1b8cb23015cde9ccf12
5
5
  SHA512:
6
- metadata.gz: 44f195eb5477138c33393704905537d3b2a66f774d5f1c645f88475cfe4b4be4bba082a15b8e99fbfc8266d74901fb38847ec83d440175db3a004ffbb085c9cd
7
- data.tar.gz: 76930a35156563532898381603c515f65fb6057b8895b5d2785c88897b75878a319067a26652dee0e33d6a992f75073011b72398a40580b7be493927e11c047e
6
+ metadata.gz: 814205156a08f49d3481545f35306f63e1f342267f126edc528b22c78ce477b4696fd4a9f401a501bb8ffe33c57f42e8e6826de830e5321289555972d841bcb3
7
+ data.tar.gz: 493a4a2ebe8803b3949797bd74061d500bb66e7a79c5d62e9dfb98af7b995a46d3a2847c88d14ad7585f8b25567ccdabdf5bdaa25eafdb045a1f3aaed7b05f13
@@ -0,0 +1,23 @@
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ rubocop:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby-version: ['3.0']
15
+ steps:
16
+ - uses: actions/checkout@v2
17
+ - name: Set up Ruby
18
+ uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby-version }}
21
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
22
+ - name: Run tests
23
+ run: bundle exec rake rubocop
@@ -0,0 +1,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby-version: ['2.6', '2.7', '3.0']
15
+ faraday-version: ['~> 0.17.3', '~> 1.7']
16
+ env:
17
+ FARADAY_VERSION: ${{ matrix.faraday-version }}
18
+ steps:
19
+ - uses: actions/checkout@v2
20
+ - name: Set up Ruby
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: ${{ matrix.ruby-version }}
24
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
25
+ - name: Run tests
26
+ run: bundle exec rake spec
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## `2.0.9`
2
+
3
+ * Support for Ruby 3.0 and Faraday 0.17.x
4
+ * Raise when directory is rate limited
5
+
6
+ ## `2.0.8`
7
+
8
+ * Add support for the keyChange endpoint
9
+
10
+ https://tools.ietf.org/html/rfc8555#section-7.3.5
11
+
12
+
13
+ ## `2.0.7`
14
+
15
+ * Add support for alternate certificate chain
16
+ * Change `Link` headers parsing to return array of value. This add support multiple entries at the same `rel`
17
+
1
18
  ## `2.0.6`
2
19
 
3
20
  * Allow Faraday up to `< 2.0`
data/Gemfile CHANGED
@@ -2,6 +2,10 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
4
 
5
+ if faraday_version = ENV['FARADAY_VERSION']
6
+ gem 'faraday', faraday_version
7
+ end
8
+
5
9
  group :development, :test do
6
10
  gem 'pry'
7
11
  gem 'rubocop', '~> 0.49.0'
data/README.md CHANGED
@@ -23,17 +23,26 @@ gem 'acme-client'
23
23
  ```
24
24
 
25
25
  ## Usage
26
- * [Setting up a client](#setting-up-a-client)
27
- * [Account management](#account-management)
28
- * [Obtaining a certificate](#obtaining-a-certificate)
29
- * [Ordering a certificate](#ordering-a-certificate)
30
- * [Completing an HTTP challenge](#preparing-for-http-challenge)
31
- * [Completing an DNS challenge](#preparing-for-dns-challenge)
32
- * [Requesting a challenge verification](#requesting-a-challenge-verification)
33
- * [Downloading a certificate](#downloading-a-certificate)
34
- * [Extra](#extra)
35
- * [Certificate revokation](#certificate-revokation)
36
- * [Certificate renewal](#certificate-renewal)
26
+ - [Acme::Client](#acmeclient)
27
+ - [Installation](#installation)
28
+ - [Usage](#usage)
29
+ - [Setting up a client](#setting-up-a-client)
30
+ - [Account management](#account-management)
31
+ - [Obtaining a certificate](#obtaining-a-certificate)
32
+ - [Ordering a certificate](#ordering-a-certificate)
33
+ - [Preparing for HTTP challenge](#preparing-for-http-challenge)
34
+ - [Preparing for DNS challenge](#preparing-for-dns-challenge)
35
+ - [Requesting a challenge verification](#requesting-a-challenge-verification)
36
+ - [Downloading a certificate](#downloading-a-certificate)
37
+ - [Ordering an alternative certificate](#ordering-an-alternative-certificate)
38
+ - [Extra](#extra)
39
+ - [Certificate revokation](#certificate-revokation)
40
+ - [Certificate renewal](#certificate-renewal)
41
+ - [Not implemented](#not-implemented)
42
+ - [Requirements](#requirements)
43
+ - [Development](#development)
44
+ - [Pull request?](#pull-request)
45
+ - [License](#license)
37
46
 
38
47
  ## Setting up a client
39
48
 
@@ -91,7 +100,7 @@ account.kid # => <kid string>
91
100
 
92
101
  If you already have an existing account (for example one created in ACME v1) please note that unless the `kid` is provided at initialization, the client will lazy load the `kid` by doing a `POST` to `newAccount` whenever the `kid` is required. Therefore, you can easily get your `kid` for an existing account and (if needed) store it for reuse:
93
102
 
94
- ```
103
+ ```ruby
95
104
  client = Acme::Client.new(private_key: private_key, directory: 'https://acme-staging-v02.api.letsencrypt.org/directory')
96
105
 
97
106
  # kid is not set, therefore a call to newAccount is made to lazy-initialize the kid
@@ -184,11 +193,28 @@ csr = Acme::Client::CertificateRequest.new(private_key: a_different_private_key,
184
193
  order.finalize(csr: csr)
185
194
  while order.status == 'processing'
186
195
  sleep(1)
187
- challenge.reload
196
+ order.reload
188
197
  end
189
198
  order.certificate # => PEM-formatted certificate
190
199
  ```
191
200
 
201
+ ### Ordering an alternative certificate
202
+
203
+ Let's Encrypt is [transitioning](https://letsencrypt.org/2019/04/15/transitioning-to-isrg-root.html) to use a new intermediate certificate. Starting January 11, 2021 new certificates will be signed by their own intermediate. To ease the transition on clients Let's Encrypt will continue signing an alternative version of the certificate using the old, cross-signed intermediate until September 29, 2021. In order to utilize an alternative certificate the `Order#certificate` method accepts a `force_chain` keyword argument, which takes the issuer name of the intermediate certificate.
204
+ For example, to download the cross-signed certificate after January 11, 2021, call `Order#certificate` as follows:
205
+
206
+ ```ruby
207
+ begin
208
+ order.certificate(force_chain: 'DST Root CA X3')
209
+ rescue Acme::Client::Error::ForcedChainNotFound
210
+ order.certificate
211
+ end
212
+ ```
213
+
214
+ Note: if the specified forced chain doesn't match an existing alternative certificate the method will raise an `Acme::Client::Error::ForcedChainNotFound` error.
215
+
216
+ Learn more about the original Github issue for this client [here](https://github.com/unixcharles/acme-client/issues/186), information from Let's Encrypt [here](https://letsencrypt.org/2019/04/15/transitioning-to-isrg-root.html), and cross-signing [here](https://letsencrypt.org/certificates/#cross-signing).
217
+
192
218
  ## Extra
193
219
 
194
220
  ### Certificate revokation
@@ -204,9 +230,15 @@ client.revoke(certificate: certificate)
204
230
  There is no renewal process, just create a new order.
205
231
 
206
232
 
207
- ## Not implemented
233
+ ### Account Key Roll-over
234
+
235
+ To change the key used for an account you can call `#account_key_change` with the new private key or jwk.
208
236
 
209
- - Account Key Roll-over.
237
+ ```ruby
238
+ require 'openssl'
239
+ new_private_key = OpenSSL::PKey::RSA.new(4096)
240
+ client.account_key_change(private_key: new_private_key)
241
+ ```
210
242
 
211
243
  ## Requirements
212
244
 
@@ -227,4 +259,3 @@ Yes.
227
259
  ## License
228
260
 
229
261
  [MIT License](http://opensource.org/licenses/MIT)
230
-
data/acme-client.gemspec CHANGED
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency 'rspec', '~> 3.9'
26
26
  spec.add_development_dependency 'vcr', '~> 2.9'
27
27
  spec.add_development_dependency 'webmock', '~> 3.8'
28
+ spec.add_development_dependency 'webrick'
28
29
 
29
30
  spec.add_runtime_dependency 'faraday', '>= 0.17', '< 2.0.0'
30
31
  end
@@ -0,0 +1,27 @@
1
+ class Acme::Client
2
+ class ChainIdentifier
3
+ def initialize(pem_certificate_chain)
4
+ @pem_certificate_chain = pem_certificate_chain
5
+ end
6
+
7
+ def match_name?(name)
8
+ issuers.any? do |issuer|
9
+ issuer.include?(name)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def issuers
16
+ x509_certificates.map(&:issuer).map(&:to_s)
17
+ end
18
+
19
+ def x509_certificates
20
+ @x509_certificates ||= splitted_pem_certificates.map { |pem| OpenSSL::X509::Certificate.new(pem) }
21
+ end
22
+
23
+ def splitted_pem_certificates
24
+ @pem_certificate_chain.each_line.slice_after(/END CERTIFICATE/).map(&:join)
25
+ end
26
+ end
27
+ end
@@ -7,6 +7,7 @@ class Acme::Client::Error < StandardError
7
7
  class UnsupportedChallengeType < ClientError; end
8
8
  class NotFound < ClientError; end
9
9
  class CertificateNotReady < ClientError; end
10
+ class ForcedChainNotFound < ClientError; end
10
11
 
11
12
  class ServerError < Acme::Client::Error; end
12
13
  class BadCSR < ServerError; end
@@ -5,10 +5,10 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
5
5
 
6
6
  CONTENT_TYPE = 'application/jose+json'
7
7
 
8
- def initialize(app, client:, mode:)
8
+ def initialize(app, options)
9
9
  super(app)
10
- @client = client
11
- @mode = mode
10
+ @client = options.fetch(:client)
11
+ @mode = options.fetch(:mode)
12
12
  end
13
13
 
14
14
  def call(env)
@@ -82,18 +82,10 @@ class Acme::Client::FaradayMiddleware < Faraday::Middleware
82
82
  end
83
83
  end
84
84
 
85
- LINK_MATCH = /<(.*?)>;rel="([\w-]+)"/
86
-
87
85
  def decode_link_headers
88
86
  return unless env.response_headers.key?('Link')
89
87
  link_header = env.response_headers['Link']
90
-
91
- links = link_header.split(', ').map { |entry|
92
- _, link, name = *entry.match(LINK_MATCH)
93
- [name, link]
94
- }
95
-
96
- Hash[*links.flatten]
88
+ Acme::Client::Util.decode_link_headers(link_header)
97
89
  end
98
90
 
99
91
  def store_nonce
@@ -73,8 +73,10 @@ class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
73
73
  # BigNumbers
74
74
  bns = ints.map(&:value)
75
75
 
76
+ byte_size = (@private_key.group.degree + 7) / 8
77
+
76
78
  # Binary R/S values
77
- r, s = bns.map { |bn| [bn.to_s(16)].pack('H*') }
79
+ r, s = bns.map { |bn| bn.to_s(2).rjust(byte_size, "\x00") }
78
80
 
79
81
  # JWS wants raw R/S concatenated.
80
82
  [r, s].join
@@ -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
@@ -5,7 +5,7 @@ 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
@@ -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
@@ -68,9 +68,13 @@ class Acme::Client::Resources::Directory
68
68
  end
69
69
 
70
70
  def fetch_directory
71
- connection = Faraday.new(url: @directory, **@connection_options)
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
72
76
  connection.headers[:user_agent] = Acme::Client::USER_AGENT
73
77
  response = connection.get(@url)
74
- JSON.parse(response.body)
78
+ response.body
75
79
  end
76
80
  end
@@ -5,7 +5,7 @@ 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
@@ -24,9 +24,9 @@ class Acme::Client::Resources::Order
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
@@ -3,6 +3,17 @@ module Acme::Client::Util
3
3
  Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
4
4
  end
5
5
 
6
+ LINK_MATCH = /<(.*?)>\s?;\s?rel="([\w-]+)"/
7
+
8
+ # See RFC 8288 - https://tools.ietf.org/html/rfc8288#section-3
9
+ def decode_link_headers(link_header)
10
+ link_header.split(',').each_with_object({}) { |entry, hash|
11
+ _, link, name = *entry.match(LINK_MATCH)
12
+ hash[name] ||= []
13
+ hash[name].push(link)
14
+ }
15
+ end
16
+
6
17
  # Sets public key on CSR or cert.
7
18
  #
8
19
  # obj - An OpenSSL::X509::Certificate or OpenSSL::X509::Request instance.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.6'.freeze
5
+ VERSION = '2.0.9'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -20,6 +20,7 @@ require 'acme/client/faraday_middleware'
20
20
  require 'acme/client/jwk'
21
21
  require 'acme/client/error'
22
22
  require 'acme/client/util'
23
+ require 'acme/client/chain_identifier'
23
24
 
24
25
  class Acme::Client
25
26
  DEFAULT_DIRECTORY = 'http://127.0.0.1:4000/directory'.freeze
@@ -84,6 +85,28 @@ class Acme::Client
84
85
  Acme::Client::Resources::Account.new(self, url: kid, **arguments)
85
86
  end
86
87
 
88
+ def account_key_change(new_private_key: nil, new_jwk: nil)
89
+ if new_private_key.nil? && new_jwk.nil?
90
+ raise ArgumentError, 'must specify new_jwk or new_private_key'
91
+ end
92
+ old_jwk = jwk
93
+ new_jwk ||= Acme::Client::JWK.from_private_key(new_private_key)
94
+
95
+ inner_payload_header = {
96
+ url: endpoint_for(:key_change)
97
+ }
98
+ inner_payload = {
99
+ account: kid,
100
+ oldKey: old_jwk.to_h
101
+ }
102
+ payload = JSON.parse(new_jwk.jws(header: inner_payload_header, payload: inner_payload))
103
+
104
+ response = post(endpoint_for(:key_change), payload: payload, mode: :kid)
105
+ arguments = attributes_from_account_response(response)
106
+ @jwk = new_jwk
107
+ Acme::Client::Resources::Account.new(self, url: kid, **arguments)
108
+ end
109
+
87
110
  def account
88
111
  @kid ||= begin
89
112
  response = post(endpoint_for(:new_account), payload: { onlyReturnExisting: true }, mode: :jwk)
@@ -127,9 +150,24 @@ class Acme::Client
127
150
  Acme::Client::Resources::Order.new(self, **arguments)
128
151
  end
129
152
 
130
- def certificate(url:)
153
+ def certificate(url:, force_chain: nil)
131
154
  response = download(url, format: :pem)
132
- response.body
155
+ pem = response.body
156
+
157
+ return pem if force_chain.nil?
158
+
159
+ return pem if ChainIdentifier.new(pem).match_name?(force_chain)
160
+
161
+ alternative_urls = Array(response.headers.dig('link', 'alternate'))
162
+ alternative_urls.each do |alternate_url|
163
+ response = download(alternate_url, format: :pem)
164
+ pem = response.body
165
+ if ChainIdentifier.new(pem).match_name?(force_chain)
166
+ return pem
167
+ end
168
+ end
169
+
170
+ raise Acme::Client::Error::ForcedChainNotFound, "Could not find any matching chain for `#{force_chain}`"
133
171
  end
134
172
 
135
173
  def authorization(url:)
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.6
4
+ version: 2.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charles Barbier
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-03-05 00:00:00.000000000 Z
11
+ date: 2021-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '3.8'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webrick
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: faraday
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -107,10 +121,11 @@ executables: []
107
121
  extensions: []
108
122
  extra_rdoc_files: []
109
123
  files:
124
+ - ".github/workflows/rubocop.yml"
125
+ - ".github/workflows/test.yml"
110
126
  - ".gitignore"
111
127
  - ".rspec"
112
128
  - ".rubocop.yml"
113
- - ".travis.yml"
114
129
  - CHANGELOG.md
115
130
  - Gemfile
116
131
  - LICENSE.txt
@@ -124,6 +139,7 @@ files:
124
139
  - lib/acme/client.rb
125
140
  - lib/acme/client/certificate_request.rb
126
141
  - lib/acme/client/certificate_request/ec_key_patch.rb
142
+ - lib/acme/client/chain_identifier.rb
127
143
  - lib/acme/client/error.rb
128
144
  - lib/acme/client/faraday_middleware.rb
129
145
  - lib/acme/client/jwk.rb
@@ -162,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
178
  - !ruby/object:Gem::Version
163
179
  version: '0'
164
180
  requirements: []
165
- rubygems_version: 3.0.3
181
+ rubygems_version: 3.1.2
166
182
  signing_key:
167
183
  specification_version: 4
168
184
  summary: Client for the ACME protocol.
data/.travis.yml DELETED
@@ -1,14 +0,0 @@
1
- language: ruby
2
- cache: bundler
3
- rvm:
4
- - 2.1
5
- - 2.2
6
- - 2.3
7
- - 2.4
8
- - 2.5
9
- - 2.6
10
- - 2.7
11
-
12
- before_install:
13
- - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true
14
- - gem install bundler -v '< 2'