acme-client 2.0.9 → 2.0.31
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 +4 -4
- data/CHANGELOG.md +96 -0
- data/Gemfile +0 -5
- data/README.md +52 -11
- data/Rakefile +1 -4
- data/acme-client.gemspec +9 -11
- data/bin/generate_keystash +9 -0
- data/lib/acme/client/certificate_request.rb +19 -2
- data/lib/acme/client/error/rate_limited.rb +15 -0
- data/lib/acme/client/error.rb +41 -0
- data/lib/acme/client/http_client.rb +173 -0
- data/lib/acme/client/jwk/ecdsa.rb +4 -4
- data/lib/acme/client/jwk/hmac.rb +30 -0
- data/lib/acme/client/jwk.rb +1 -0
- data/lib/acme/client/resources/account.rb +4 -2
- data/lib/acme/client/resources/authorization.rb +14 -4
- data/lib/acme/client/resources/challenges/base.rb +15 -3
- data/lib/acme/client/resources/challenges/dns_account01.rb +30 -0
- data/lib/acme/client/resources/challenges.rb +3 -1
- data/lib/acme/client/resources/directory.rb +17 -30
- data/lib/acme/client/resources/order.rb +25 -4
- data/lib/acme/client/resources/renewal_info.rb +54 -0
- data/lib/acme/client/resources.rb +1 -0
- data/lib/acme/client/util.rb +56 -1
- data/lib/acme/client/version.rb +1 -1
- data/lib/acme/client.rb +89 -47
- metadata +68 -38
- data/.github/workflows/rubocop.yml +0 -23
- data/.github/workflows/test.yml +0 -26
- data/.gitignore +0 -12
- data/.rspec +0 -3
- data/.rubocop.yml +0 -134
- data/lib/acme/client/faraday_middleware.rb +0 -111
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55d60fbf24893ea26c59535b2ffe6041916e678d4bbea5982666c5f9eb0f2bae
|
|
4
|
+
data.tar.gz: b62818cd2dc3ca8b9676e7ec355dfcec40eaf90b3f9b7abc26b2dddea0cc4473
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3311579f5f990f433e2ede868ce5baf6ce910eb38b19889107436a0dea91f7d96c36619e6039e0e4b4382402e8bc81dce939fee0f915c850b068b80ced67c1d
|
|
7
|
+
data.tar.gz: 45b7de2260bad0703cfa5f865a33e5367789cde176e0a906224fc7e7ad29ba5eeb9f756ce79b29719460c03264c26c5808702737be912d2f5df9cce38003b2f9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
+
## `2.0.31`
|
|
2
|
+
|
|
3
|
+
* Expose Retry-After header on all
|
|
4
|
+
* ARI improvement
|
|
5
|
+
* Expose full error message on Error#acme_error_body
|
|
6
|
+
* Expose error subproblems (RFC7807) on Error#subproblems
|
|
7
|
+
|
|
8
|
+
## `2.0.30`
|
|
9
|
+
|
|
10
|
+
* Add a default message to RateLimited error
|
|
11
|
+
|
|
12
|
+
This fix avoid argument error on RateLimited object when stubbing without passing arguments.
|
|
13
|
+
|
|
14
|
+
## `2.0.29`
|
|
15
|
+
|
|
16
|
+
* IP support to the CertificateRequest helper
|
|
17
|
+
|
|
18
|
+
## `2.0.28`
|
|
19
|
+
|
|
20
|
+
* Make [Retry-After](https://datatracker.ietf.org/doc/html/rfc8555/#section-6.6) accessible from RateLimited#retry_after exceptions
|
|
21
|
+
|
|
22
|
+
## `2.0.27`
|
|
23
|
+
|
|
24
|
+
* Add support for Renewal Information (ARI) (RFC 9773)
|
|
25
|
+
|
|
26
|
+
## `2.0.26`
|
|
27
|
+
|
|
28
|
+
* Add support for dns-account-01 challenge (RFC draft-ietf-acme-dns-account-label-01)
|
|
29
|
+
|
|
30
|
+
## `2.0.25`
|
|
31
|
+
|
|
32
|
+
* Add support for profiles extension
|
|
33
|
+
|
|
34
|
+
## `2.0.24`
|
|
35
|
+
|
|
36
|
+
* Add support for account orders url attribute.
|
|
37
|
+
|
|
38
|
+
## `2.0.23`
|
|
39
|
+
|
|
40
|
+
* Allow Order to be create without url. Location is not always required in the specification.
|
|
41
|
+
|
|
42
|
+
## `2.0.22`
|
|
43
|
+
|
|
44
|
+
* Loosen base64 dependency constraint
|
|
45
|
+
|
|
46
|
+
## `2.0.21`
|
|
47
|
+
|
|
48
|
+
* Add validated attribute to challenges
|
|
49
|
+
|
|
50
|
+
## `2.0.20`
|
|
51
|
+
|
|
52
|
+
* Add OrderNotReady exception
|
|
53
|
+
|
|
54
|
+
## `2.0.19`
|
|
55
|
+
|
|
56
|
+
* Fix an issue CSR generation. Version should be set to zero according to the spec. It's causing issue with some ACME server implementation.
|
|
57
|
+
|
|
58
|
+
## `2.0.18`
|
|
59
|
+
|
|
60
|
+
* Fix an issue public key encoding. `OpenSSL::BN` cause keys with leading zero to fail.
|
|
61
|
+
|
|
62
|
+
## `2.0.17`
|
|
63
|
+
|
|
64
|
+
* Fix bug where depending on call order `jws` get generated with the wrong `kid`
|
|
65
|
+
|
|
66
|
+
## `2.0.16`
|
|
67
|
+
|
|
68
|
+
* Refactor Directory
|
|
69
|
+
* Fix an issue where the client would crash when ACME provider return nonce for directory endpoint
|
|
70
|
+
|
|
71
|
+
## `2.0.15`
|
|
72
|
+
|
|
73
|
+
* Also pass connection_options to Faraday for Client#get_nonce
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## `2.0.14`
|
|
77
|
+
|
|
78
|
+
* Fix Faraday HTTP exceptions leaking out, always raise `Acme::Client::Error` instead
|
|
79
|
+
|
|
80
|
+
## `2.0.13`
|
|
81
|
+
|
|
82
|
+
* Add support for External Account Binding
|
|
83
|
+
|
|
84
|
+
## `2.0.12`
|
|
85
|
+
|
|
86
|
+
* Update test matrix to current Ruby versions (2.7 to 3.2)
|
|
87
|
+
* Support for Faraday retry 2.x
|
|
88
|
+
|
|
89
|
+
## `2.0.11`
|
|
90
|
+
|
|
91
|
+
* Add support for error code `AlreadyRevoked` and `BadPublicKey`
|
|
92
|
+
|
|
93
|
+
## `2.0.10`
|
|
94
|
+
|
|
95
|
+
* Support for Faraday 1.0 / 2.0
|
|
96
|
+
|
|
1
97
|
## `2.0.9`
|
|
2
98
|
|
|
3
99
|
* Support for Ruby 3.0 and Faraday 0.17.x
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
# Acme::Client
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-
|
|
5
|
-
`acme-client` is a client implementation of the ACMEv2 / [RFC 8555](https://tools.ietf.org/html/rfc8555) protocol in Ruby.
|
|
3
|
+
`acme-client` is a client implementation of the ACME / [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.
|
|
8
6
|
|
|
9
7
|
ACME is part of the [Letsencrypt](https://letsencrypt.org/) project, which goal is to provide free SSL/TLS certificates with automation of the acquiring and renewal process.
|
|
10
8
|
|
|
11
|
-
You can find ACMEv1 compatible client in the [acme-v1](https://github.com/unixcharles/acme-client/tree/acme-v1) branch.
|
|
12
|
-
|
|
13
9
|
## Installation
|
|
14
10
|
|
|
15
11
|
Via RubyGems:
|
|
@@ -108,6 +104,15 @@ client.kid
|
|
|
108
104
|
=> "https://acme-staging-v02.api.letsencrypt.org/acme/acct/000000"
|
|
109
105
|
```
|
|
110
106
|
|
|
107
|
+
## External Account Binding support
|
|
108
|
+
|
|
109
|
+
You can use External Account Binding by providing a `external_account_binding` with a `kid` and `hmac_key`.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
client = Acme::Client.new(private_key: private_key, directory: 'https://acme.zerossl.com/v2/DV90')
|
|
113
|
+
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"})
|
|
114
|
+
```
|
|
115
|
+
|
|
111
116
|
## Obtaining a certificate
|
|
112
117
|
### Ordering a certificate
|
|
113
118
|
|
|
@@ -115,9 +120,11 @@ To order a new certificate, the client must provide a list of identifiers.
|
|
|
115
120
|
|
|
116
121
|
The returned order will contain a list of `Authorization` that need to be completed in other to finalize the order, generally one per identifier.
|
|
117
122
|
|
|
118
|
-
Each authorization contains multiple challenges, typically a `dns-01` and a `http-01` challenge. The applicant is only required to complete one of the challenges.
|
|
123
|
+
Each authorization contains multiple challenges, typically a `dns-01`, `dns-account-01`, and a `http-01` challenge. The applicant is only required to complete one of the challenges.
|
|
119
124
|
|
|
120
|
-
|
|
125
|
+
The `dns-account-01` challenge prepends an account-specific label before `_acme-challenge`, producing a record name of the form `_<label>._acme-challenge` so different clients can validate the same domain concurrently.
|
|
126
|
+
|
|
127
|
+
You can access the challenge you wish to complete using the `#dns`, `#dns_account`, or `#http` methods.
|
|
121
128
|
|
|
122
129
|
```ruby
|
|
123
130
|
order = client.new_order(identifiers: ['example.com'])
|
|
@@ -160,6 +167,25 @@ dns_challenge.record_type # => 'TXT'
|
|
|
160
167
|
dns_challenge.record_content # => 'HRV3PS5sRDyV-ous4HJk4z24s5JjmUTjcCaUjFt28-8'
|
|
161
168
|
```
|
|
162
169
|
|
|
170
|
+
### Preparing for DNS-Account-01 challenge
|
|
171
|
+
|
|
172
|
+
To complete the DNS-Account-01 challenge, you must set a DNS TXT record using an account-specific name. This allows multiple ACME clients to validate the same domain concurrently without conflicts.
|
|
173
|
+
|
|
174
|
+
The DNSAccount01 object has utility methods to generate the required DNS record:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
dns_account_challenge = authorization.dns_account
|
|
178
|
+
|
|
179
|
+
dns_account_challenge.record_name # => '_ujmmovf2vn55tgye._acme-challenge'
|
|
180
|
+
dns_account_challenge.record_type # => 'TXT'
|
|
181
|
+
dns_account_challenge.record_content # => 'HRV3PS5sRDyV-ous4HJk4z24s5JjmUTjcCaUjFt28-8'
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The record name includes an account-specific label derived from your account URL, ensuring different clients can validate simultaneously:
|
|
185
|
+
|
|
186
|
+
- **DNS-01**: `_acme-challenge.example.com` (shared)
|
|
187
|
+
- **DNS-Account-01**: `_ujmmovf2vn55tgye._acme-challenge.example.com` (account-specific)
|
|
188
|
+
|
|
163
189
|
### Requesting a challenge verification
|
|
164
190
|
|
|
165
191
|
Once you are ready to complete the challenge, you can request the server perform the verification.
|
|
@@ -200,8 +226,7 @@ order.certificate # => PEM-formatted certificate
|
|
|
200
226
|
|
|
201
227
|
### Ordering an alternative certificate
|
|
202
228
|
|
|
203
|
-
|
|
204
|
-
For example, to download the cross-signed certificate after January 11, 2021, call `Order#certificate` as follows:
|
|
229
|
+
The provider may provide alternate certificate with different certificate chain. You can specify the required chain and the client will automatically download alternate certificate and match the chain by name.
|
|
205
230
|
|
|
206
231
|
```ruby
|
|
207
232
|
begin
|
|
@@ -237,12 +262,28 @@ To change the key used for an account you can call `#account_key_change` with th
|
|
|
237
262
|
```ruby
|
|
238
263
|
require 'openssl'
|
|
239
264
|
new_private_key = OpenSSL::PKey::RSA.new(4096)
|
|
240
|
-
client.account_key_change(
|
|
265
|
+
client.account_key_change(new_private_key: new_private_key)
|
|
241
266
|
```
|
|
242
267
|
|
|
268
|
+
### Profile Extension
|
|
269
|
+
|
|
270
|
+
Provide a CA profile when creating a new order:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
order = client.new_order(identifiers: ['example.com'], profile: 'shortlived')
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
ACME servers may list supported profiles in the directory endpoint:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
client.profiles => {"classic": "https://example.com/docs/classic", "shortlived": "https://example.com/docs/shortlived"}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
See the [RFC draft of certificate profiles](https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/) for more info.
|
|
283
|
+
|
|
243
284
|
## Requirements
|
|
244
285
|
|
|
245
|
-
Ruby >=
|
|
286
|
+
Ruby >= 3.0
|
|
246
287
|
|
|
247
288
|
## Development
|
|
248
289
|
|
data/Rakefile
CHANGED
data/acme-client.gemspec
CHANGED
|
@@ -11,21 +11,19 @@ Gem::Specification.new do |spec|
|
|
|
11
11
|
spec.homepage = 'http://github.com/unixcharles/acme-client'
|
|
12
12
|
spec.license = 'MIT'
|
|
13
13
|
|
|
14
|
-
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
14
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) || f.start_with?('.') }
|
|
15
15
|
spec.require_paths = ['lib']
|
|
16
16
|
|
|
17
|
-
spec.required_ruby_version = '>= 2.
|
|
17
|
+
spec.required_ruby_version = '>= 2.3.0'
|
|
18
18
|
|
|
19
|
-
spec.add_development_dependency '
|
|
20
|
-
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.2')
|
|
21
|
-
spec.add_development_dependency 'rake', '>= 12.0'
|
|
22
|
-
else
|
|
23
|
-
spec.add_development_dependency 'rake', '~> 13.0'
|
|
24
|
-
end
|
|
19
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
25
20
|
spec.add_development_dependency 'rspec', '~> 3.9'
|
|
26
|
-
spec.add_development_dependency 'vcr', '~>
|
|
21
|
+
spec.add_development_dependency 'vcr', '~> 6.0'
|
|
22
|
+
spec.add_development_dependency 'bigdecimal'
|
|
27
23
|
spec.add_development_dependency 'webmock', '~> 3.8'
|
|
28
|
-
spec.add_development_dependency 'webrick'
|
|
24
|
+
spec.add_development_dependency 'webrick', '~> 1.7'
|
|
29
25
|
|
|
30
|
-
spec.add_runtime_dependency '
|
|
26
|
+
spec.add_runtime_dependency 'base64', '~> 0.2'
|
|
27
|
+
spec.add_runtime_dependency 'faraday', '>= 1.0', '< 3.0.0'
|
|
28
|
+
spec.add_runtime_dependency 'faraday-retry', '>= 1.0', '< 3.0.0'
|
|
31
29
|
end
|
|
@@ -89,7 +89,7 @@ class Acme::Client::CertificateRequest
|
|
|
89
89
|
end
|
|
90
90
|
csr.public_key = @private_key
|
|
91
91
|
csr.subject = generate_subject
|
|
92
|
-
csr.version =
|
|
92
|
+
csr.version = 0
|
|
93
93
|
add_extension(csr)
|
|
94
94
|
csr.sign @private_key, @digest
|
|
95
95
|
end
|
|
@@ -104,8 +104,15 @@ class Acme::Client::CertificateRequest
|
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def add_extension(csr)
|
|
107
|
+
san = @names.map do |name|
|
|
108
|
+
if valid_ip_address?(name)
|
|
109
|
+
"IP:#{name}"
|
|
110
|
+
else
|
|
111
|
+
"DNS:#{name}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
107
114
|
extension = OpenSSL::X509::ExtensionFactory.new.create_extension(
|
|
108
|
-
'subjectAltName',
|
|
115
|
+
'subjectAltName', san.join(', '), false
|
|
109
116
|
)
|
|
110
117
|
csr.add_attribute(
|
|
111
118
|
OpenSSL::X509::Attribute.new(
|
|
@@ -116,4 +123,14 @@ class Acme::Client::CertificateRequest
|
|
|
116
123
|
end
|
|
117
124
|
end
|
|
118
125
|
|
|
126
|
+
def valid_ip_address?(address)
|
|
127
|
+
require 'ipaddr'
|
|
128
|
+
begin
|
|
129
|
+
ip = IPAddr.new(address)
|
|
130
|
+
true
|
|
131
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
119
136
|
require 'acme/client/certificate_request/ec_key_patch'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class Acme::Client::Error::RateLimited < Acme::Client::Error::ServerError
|
|
2
|
+
DEFAULT_MESSAGE = 'Error message: urn:ietf:params:acme:error:rateLimited'
|
|
3
|
+
DEFAULT_RETRY_SECONDS = 10
|
|
4
|
+
|
|
5
|
+
def initialize(message = DEFAULT_MESSAGE, retry_after = nil, acme_error_body: nil, subproblems: nil)
|
|
6
|
+
retry_after_time = case retry_after
|
|
7
|
+
when Time then retry_after
|
|
8
|
+
when nil then Time.now + DEFAULT_RETRY_SECONDS
|
|
9
|
+
else Acme::Client::Util.parse_retry_after(retry_after) || Time.now + DEFAULT_RETRY_SECONDS
|
|
10
|
+
end
|
|
11
|
+
int_retry_after = retry_after.nil? ? DEFAULT_RETRY_SECONDS : [(retry_after_time - Time.now).ceil, 0].max
|
|
12
|
+
super(message, retry_after: int_retry_after, acme_error_body: acme_error_body, subproblems: subproblems)
|
|
13
|
+
@retry_after_time = retry_after_time
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/acme/client/error.rb
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
class Acme::Client::Error < StandardError
|
|
2
|
+
attr_reader :retry_after, :retry_after_time, :subproblems, :acme_error_body
|
|
3
|
+
|
|
4
|
+
Subproblem = Struct.new(:type, :detail, :identifier, keyword_init: true) do
|
|
5
|
+
def to_h
|
|
6
|
+
{ type: type, detail: detail, identifier: identifier }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, retry_after: nil, acme_error_body: nil, subproblems: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
|
|
13
|
+
@retry_after = @retry_after_time ? [(@retry_after_time - Time.now).ceil, 0].max : nil
|
|
14
|
+
@acme_error_body = acme_error_body
|
|
15
|
+
@subproblems = parse_subproblems(subproblems)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def parse_subproblems(raw)
|
|
21
|
+
return [] if raw.nil? || !raw.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
raw.map do |sp|
|
|
24
|
+
Subproblem.new(
|
|
25
|
+
type: sp['type'],
|
|
26
|
+
detail: sp['detail'],
|
|
27
|
+
identifier: sp['identifier']
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
public
|
|
33
|
+
|
|
2
34
|
class Timeout < Acme::Client::Error; end
|
|
3
35
|
|
|
4
36
|
class ClientError < Acme::Client::Error; end
|
|
@@ -8,10 +40,15 @@ class Acme::Client::Error < StandardError
|
|
|
8
40
|
class NotFound < ClientError; end
|
|
9
41
|
class CertificateNotReady < ClientError; end
|
|
10
42
|
class ForcedChainNotFound < ClientError; end
|
|
43
|
+
class OrderNotReady < ClientError; end
|
|
44
|
+
class OrderUrlNil < ClientError; end
|
|
11
45
|
|
|
12
46
|
class ServerError < Acme::Client::Error; end
|
|
47
|
+
class AlreadyReplaced < ServerError; end
|
|
48
|
+
class AlreadyRevoked < ServerError; end
|
|
13
49
|
class BadCSR < ServerError; end
|
|
14
50
|
class BadNonce < ServerError; end
|
|
51
|
+
class BadPublicKey < ServerError; end
|
|
15
52
|
class BadSignatureAlgorithm < ServerError; end
|
|
16
53
|
class InvalidContact < ServerError; end
|
|
17
54
|
class UnsupportedContact < ServerError; end
|
|
@@ -32,14 +69,18 @@ class Acme::Client::Error < StandardError
|
|
|
32
69
|
class IncorrectResponse < ServerError; end
|
|
33
70
|
|
|
34
71
|
ACME_ERRORS = {
|
|
72
|
+
'urn:ietf:params:acme:error:alreadyReplaced' => AlreadyReplaced,
|
|
73
|
+
'urn:ietf:params:acme:error:alreadyRevoked' => AlreadyRevoked,
|
|
35
74
|
'urn:ietf:params:acme:error:badCSR' => BadCSR,
|
|
36
75
|
'urn:ietf:params:acme:error:badNonce' => BadNonce,
|
|
76
|
+
'urn:ietf:params:acme:error:badPublicKey' => BadPublicKey,
|
|
37
77
|
'urn:ietf:params:acme:error:badSignatureAlgorithm' => BadSignatureAlgorithm,
|
|
38
78
|
'urn:ietf:params:acme:error:invalidContact' => InvalidContact,
|
|
39
79
|
'urn:ietf:params:acme:error:unsupportedContact' => UnsupportedContact,
|
|
40
80
|
'urn:ietf:params:acme:error:externalAccountRequired' => ExternalAccountRequired,
|
|
41
81
|
'urn:ietf:params:acme:error:accountDoesNotExist' => AccountDoesNotExist,
|
|
42
82
|
'urn:ietf:params:acme:error:malformed' => Malformed,
|
|
83
|
+
'urn:ietf:params:acme:error:orderNotReady' => OrderNotReady,
|
|
43
84
|
'urn:ietf:params:acme:error:rateLimited' => RateLimited,
|
|
44
85
|
'urn:ietf:params:acme:error:rejectedIdentifier' => RejectedIdentifier,
|
|
45
86
|
'urn:ietf:params:acme:error:serverInternal' => ServerInternal,
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
retry_after = env.response_headers['retry-after']
|
|
105
|
+
body = env.body.is_a?(Hash) ? env.body : nil
|
|
106
|
+
subproblems = error_subproblems
|
|
107
|
+
if error_class == Acme::Client::Error::RateLimited
|
|
108
|
+
raise error_class.new(error_message, retry_after, acme_error_body: body, subproblems: subproblems)
|
|
109
|
+
end
|
|
110
|
+
raise error_class.new(error_message, retry_after: retry_after, acme_error_body: body, subproblems: subproblems)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def error_message
|
|
114
|
+
if env.body.is_a? Hash
|
|
115
|
+
env.body['detail']
|
|
116
|
+
else
|
|
117
|
+
"Error message: #{env.body}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def error_class
|
|
122
|
+
Acme::Client::Error::ACME_ERRORS.fetch(error_name, Acme::Client::Error)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def error_name
|
|
126
|
+
return unless env.body.is_a?(Hash)
|
|
127
|
+
return unless env.body.key?('type')
|
|
128
|
+
env.body['type']
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def error_subproblems
|
|
132
|
+
return unless env.body.is_a?(Hash)
|
|
133
|
+
env.body['subproblems']
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def decode_body
|
|
137
|
+
content_type = env.response_headers['Content-Type'].to_s
|
|
138
|
+
|
|
139
|
+
if content_type.start_with?('application/json', 'application/problem+json')
|
|
140
|
+
JSON.load(env.body)
|
|
141
|
+
else
|
|
142
|
+
env.body
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def decode_link_headers
|
|
147
|
+
return unless env.response_headers.key?('Link')
|
|
148
|
+
link_header = env.response_headers['Link']
|
|
149
|
+
Acme::Client::Util.decode_link_headers(link_header)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def store_nonce
|
|
153
|
+
nonce = env.response_headers['replay-nonce']
|
|
154
|
+
nonces << nonce if nonce
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pop_nonce
|
|
158
|
+
if nonces.empty?
|
|
159
|
+
get_nonce
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
nonces.pop
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def get_nonce
|
|
166
|
+
client.get_nonce
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def nonces
|
|
170
|
+
client.nonces
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -50,8 +50,8 @@ class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
|
|
|
50
50
|
{
|
|
51
51
|
crv: @curve_params[:jwa_crv],
|
|
52
52
|
kty: 'EC',
|
|
53
|
-
x: Acme::Client::Util.urlsafe_base64(coordinates[:x]
|
|
54
|
-
y: Acme::Client::Util.urlsafe_base64(coordinates[:y]
|
|
53
|
+
x: Acme::Client::Util.urlsafe_base64(coordinates[:x]),
|
|
54
|
+
y: Acme::Client::Util.urlsafe_base64(coordinates[:y])
|
|
55
55
|
}
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -92,8 +92,8 @@ class Acme::Client::JWK::ECDSA < Acme::Client::JWK::Base
|
|
|
92
92
|
hex_y = hex[2 + data_len / 2, data_len / 2]
|
|
93
93
|
|
|
94
94
|
{
|
|
95
|
-
x:
|
|
96
|
-
y:
|
|
95
|
+
x: [hex_x].pack('H*'),
|
|
96
|
+
y: [hex_y].pack('H*')
|
|
97
97
|
}
|
|
98
98
|
end
|
|
99
99
|
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
|
data/lib/acme/client/jwk.rb
CHANGED
|
@@ -34,16 +34,18 @@ class Acme::Client::Resources::Account
|
|
|
34
34
|
url: url,
|
|
35
35
|
term_of_service: term_of_service,
|
|
36
36
|
status: status,
|
|
37
|
-
contact: contact
|
|
37
|
+
contact: contact,
|
|
38
|
+
orders: orders_url
|
|
38
39
|
}
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
43
|
-
def assign_attributes(url:, term_of_service:, status:, contact:)
|
|
44
|
+
def assign_attributes(url:, term_of_service:, status:, contact:, orders: nil)
|
|
44
45
|
@url = url
|
|
45
46
|
@term_of_service = term_of_service
|
|
46
47
|
@status = status
|
|
47
48
|
@contact = Array(contact)
|
|
49
|
+
@orders_url = orders
|
|
48
50
|
end
|
|
49
51
|
end
|