acme-client 1.0.0 → 2.0.0

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
- SHA1:
3
- metadata.gz: 4f48de1046df3b5b987afb2b8c3175d022459efd
4
- data.tar.gz: d138fca27e0b9b363fd84e85882fe8fa37d61c31
2
+ SHA256:
3
+ metadata.gz: ccaa8fb7fadbae1a6d93f99ad319ca95261994cbec494ab885028aae2f894a6a
4
+ data.tar.gz: 224d05d0f66cb37ffcbcf1bf05f649e0278dc097518b927dcc4a2e635ba29357
5
5
  SHA512:
6
- metadata.gz: ee22b724906361bcd48f668a47472c7d78dc624d88b375d94ea3efa96e359decf674387f17f935a2fcc2f5583f31735921f02addad3b996ef710d24b3e155415
7
- data.tar.gz: 3886e12ad0d8478a1013bec98fd1e491e259fa39a4623d90d6be3416661d11c2338dbc9487f3284f0b71fb0dfbc848abe18dc392eede667218942c771555a286
6
+ metadata.gz: c461b3d255fc35d3b21411632ff4f26700fd0969547f8a7a93879c719e2383f58badfc0278bc45df4780b293094af3b2d560494ac3f00076dae23affbe697682
7
+ data.tar.gz: 477869ea08075d8e9d70afa80f4576f6a0ffb936bfb70244b0bf6f8d34d2da593d76acf6231d266fd920eeb0fbf4045e4605869c77f9b06377487cbb4902c014
@@ -99,5 +99,41 @@ Style/BlockDelimiters:
99
99
  Style/Lambda:
100
100
  Enabled: false
101
101
 
102
+ Style/GuardClause:
103
+ Enabled: false
104
+
105
+ Style/Alias:
106
+ Enabled: false
107
+
108
+ Lint/AmbiguousOperator:
109
+ Enabled: false
110
+
111
+ Metrics/MethodLength:
112
+ Enabled: false
113
+
114
+ Metrics/PerceivedComplexity:
115
+ Enabled: false
116
+
117
+ Metrics/CyclomaticComplexity:
118
+ Enabled: false
119
+
120
+ Metrics/AbcSize:
121
+ Enabled: false
122
+
123
+ Metrics/ClassLength:
124
+ Enabled: false
125
+
126
+ Style/MutableConstant:
127
+ Enabled: false
128
+
129
+ Style/GlobalVars:
130
+ Enabled: false
131
+
132
+ Style/ExpandPathArguments:
133
+ Enabled: false
134
+
135
+ Security/JSONLoad:
136
+ Enabled: false
137
+
102
138
  Naming/AccessorMethodName:
103
139
  Enabled: false
data/README.md CHANGED
@@ -1,17 +1,15 @@
1
- # Deprecated
2
-
3
- This branch track the client for ACMEv1.
4
-
5
1
  # Acme::Client
6
2
 
7
3
  [![Build Status](https://travis-ci.org/unixcharles/acme-client.svg?branch=master)](https://travis-ci.org/unixcharles/acme-client)
8
4
 
9
- `acme-client` is a client implementation of the [ACME](https://github.com/ietf-wg-acme/acme/) protocol in Ruby.
5
+ `acme-client` is a client implementation of the [ACMEv2](https://github.com/ietf-wg-acme/acme) protocol in Ruby.
10
6
 
11
- You can find the ACME reference implementations of the [server](https://github.com/letsencrypt/boulder) in Go and the [client](https://github.com/letsencrypt/letsencrypt) in Python.
7
+ 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.
12
8
 
13
9
  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.
14
10
 
11
+ You can find ACMEv1 compatible client in the [acme-v1](https://github.com/unixcharles/acme-client/tree/acme-v1) branch.
12
+
15
13
  ## Installation
16
14
 
17
15
  Via RubyGems:
@@ -25,143 +23,189 @@ gem 'acme-client'
25
23
  ```
26
24
 
27
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)
37
+
38
+ ## Setting up a client
39
+
40
+ The client is initialized with a private key and the directory of your ACME provider.
41
+
42
+ LetsEncrypt's `directory` is `https://acme-v02.api.letsencrypt.org/directory`.
43
+
44
+ They also have a staging enpoind at `https://acme-staging-v02.api.letsencrypt.org/directory`.
28
45
 
29
- ### Register client
46
+ `acme-ruby` expects `OpenSSL::PKey::RSA` or `OpenSSL::PKey::EC`
30
47
 
31
- In order to authenticate our client, we have to create an account for it.
48
+ You can generate one in Ruby using OpenSSL.
32
49
 
33
50
  ```ruby
34
- # We're going to need a private key.
35
51
  require 'openssl'
36
52
  private_key = OpenSSL::PKey::RSA.new(4096)
53
+ ```
54
+
55
+ Or load one from a PEM file
56
+
57
+ ```ruby
58
+ require 'openssl'
59
+ OpenSSL::PKey::RSA.new(File.read('/path/to/private_key.pem'))
60
+ ```
37
61
 
38
- # We need an ACME server to talk to, see github.com/letsencrypt/boulder
39
- # WARNING: This endpoint is the production endpoint, which is rate limited and will produce valid certificates.
40
- # You should probably use the staging endpoint for all your experimentation:
41
- # endpoint = 'https://acme-staging.api.letsencrypt.org/'
42
- endpoint = 'https://acme-v01.api.letsencrypt.org/'
62
+ See [RSA](https://ruby.github.io/openssl/OpenSSL/PKey/RSA.html) and [EC](https://ruby.github.io/openssl/OpenSSL/PKey/EC.html) for documentation.
43
63
 
44
- # Initialize the client
45
- require 'acme-client'
46
- client = Acme::Client.new(private_key: private_key, endpoint: endpoint, connection_options: { request: { open_timeout: 5, timeout: 5 } })
47
64
 
48
- # If the private key is not known to the server, we need to register it for the first time.
49
- registration = client.register(contact: 'mailto:contact@example.com')
65
+ ```ruby
66
+ client = Acme::Client.new(private_key: private_key, directory: 'https://acme-staging-v02.api.letsencrypt.org/directory')
67
+ ```
68
+
69
+ If your account is already registered, you can save some API calls by passing your key ID directly. This will avoid an unnecessary API call to retrieve it from your private key.
70
+
71
+ ```ruby
72
+ client = Acme::Client.new(private_key: private_key, directory: 'https://acme-staging-v02.api.letsencrypt.org/directory', kid: 'https://example.com/acme/acct/1')
73
+ ```
74
+
75
+ ## Account management
76
+
77
+ Accounts are tied to a private key. Before being allowed to create orders, the account must be registered and the ToS accepted using the private key. The account will be assigned a key ID.
78
+
79
+ ```ruby
80
+ client = Acme::Client.new(private_key: private_key, directory: 'https://acme-staging-v02.api.letsencrypt.org/directory')
81
+ account = client.new_account(contact: 'mailto:info@example.com', terms_of_service_agreed: true)
82
+ ```
83
+
84
+ After the registration you can retrieve the account key indentifier (kid).
50
85
 
51
- # You may need to agree to the terms of service (that's up the to the server to require it or not but boulder does by default)
52
- registration.agree_terms
86
+ ```ruby
87
+ client = Acme::Client.new(private_key: private_key, directory: 'https://acme-staging-v02.api.letsencrypt.org/directory')
88
+ account = client.new_account(contact: 'mailto:info@example.com', terms_of_service_agreed: true)
89
+ account.kid # => <kid string>
53
90
  ```
54
91
 
55
- ### Authorize for domain
92
+ ## Obtaining a certificate
93
+ ### Ordering a certificate
94
+
95
+ To order a new certificate, the client must provide a list of identifiers.
96
+
97
+ The returned order will contain a list of `Authorization` that need to be completed in other to finalize the order, generally one per identifier.
98
+
99
+ Each authorization contains multiple challenges, typically a `dns-01` and a `http-01` challenge. The applicant is only required to complete one the challenges.
56
100
 
57
- Before you are able to obtain certificates for your domain, you have to prove that you are in control of it.
101
+ You can access the challenge you wish to complete using the `#dns` or `#http` method.
58
102
 
59
103
  ```ruby
60
- authorization = client.authorize(domain: 'example.org')
104
+ order = client.new_order(identifiers: ['example.com'])
105
+ authorization = order.authorizations.first
106
+ challenge = authorization.http
107
+ ```
61
108
 
62
- # If authorization.status returns 'valid' here you can already get a certificate
63
- # and _must not_ try to solve another challenge.
64
- authorization.status # => 'pending'
109
+ ### Preparing for HTTP challenge
65
110
 
66
- # You can can store the authorization's URI to fully recover it and
67
- # any associated challenges via Acme::Client#fetch_authorization.
68
- authorization.uri # => '...'
111
+ To complete the HTTP challenge, you must return a file using HTTP.
69
112
 
70
- # This example is using the http-01 challenge type. Other challenges are dns-01 or tls-sni-01.
71
- challenge = authorization.http01
113
+ The path follows the following format:
72
114
 
73
- # The http-01 method will require you to respond to a HTTP request.
115
+ > .well-known/acme-challenge/#{token}
74
116
 
75
- # You can retrieve the challenge token
76
- challenge.token # => "some_token"
117
+ And the file content is the key authorization. The HTTP01 object has utility methods to generate them.
77
118
 
78
- # You can retrieve the expected path for the file.
79
- challenge.filename # => ".well-known/acme-challenge/:some_token"
119
+ ```ruby
120
+ > http_challenge.content_type # => 'text/plain'
121
+ > http_challenge.file_content # => example_token.TO1xJ0UDgfQ8WY5zT3txynup87UU3PhcDEIcuPyw4QU
122
+ > http_challenge.filename # => '.well-known/acme-challenge/example_token'
123
+ > http_challenge.token # => 'example_token'
124
+ ```
80
125
 
81
- # You can generate the body of the expected response.
82
- challenge.file_content # => 'string token and JWK thumbprint'
126
+ For test purposes you can just save the challenge file and use Ruby to serve it:
83
127
 
84
- # You are not required to send a Content-Type. This method will return the right Content-Type should you decide to include one.
85
- challenge.content_type
128
+ ```bash
129
+ ruby -run -e httpd public -p 8080 --bind-address 0.0.0.0
130
+ ```
131
+
132
+ ### Preparing for DNS challenge
86
133
 
87
- # Save the file. We'll create a public directory to serve it from, and inside it we'll create the challenge file.
88
- FileUtils.mkdir_p( File.join( 'public', File.dirname( challenge.filename ) ) )
134
+ To complete the DNS challenge, you must set a DNS record to prove that you control the domain.
89
135
 
90
- # We'll write the content of the file
91
- File.write( File.join( 'public', challenge.filename), challenge.file_content )
136
+ The DNS01 object has utility methods to generate them.
92
137
 
93
- # Optionally save the authorization URI for use at another time (eg: by a background job processor)
94
- File.write('authorization_uri', authorization.uri)
138
+ ```ruby
139
+ dns_challenge.record_name # => '_acme-challenge'
140
+ dns_challenge.record_type # => 'TXT'
141
+ dns_challenge.record_content # => 'HRV3PS5sRDyV-ous4HJk4z24s5JjmUTjcCaUjFt28-8'
142
+ ```
95
143
 
96
- # The challenge file can be served with a Ruby webserver.
97
- # You can run a webserver in another console for that purpose. You may need to forward ports on your router.
98
- #
99
- # $ ruby -run -e httpd public -p 8080 --bind-address 0.0.0.0
144
+ ### Requesting a challenge verification
100
145
 
101
- # Load a challenge based on stored authorization URI. This is only required if you need to reuse a challenge as outlined above.
102
- challenge = client.fetch_authorization(File.read('authorization_uri')).http01
146
+ Once you are ready to complete the challenge, you can request the server perform the verification.
103
147
 
104
- # Once you are ready to serve the confirmation request you can proceed.
105
- challenge.request_verification # => true
106
- challenge.authorization.verify_status # => 'pending'
148
+ ```ruby
149
+ challenge.request_validation
150
+ ```
107
151
 
108
- # Wait a bit for the server to make the request, or just blink. It should be fast.
109
- sleep(1)
152
+ The validation is performed asynchronously and can take some time to be performed by the server.
110
153
 
111
- # Rely on authorization.verify_status more than on challenge.verify_status,
112
- # if the former is 'valid' you can already issue a certificate and the status of
113
- # the challenge is not relevant and in fact may never change from pending.
114
- challenge.authorization.verify_status # => 'valid'
115
- challenge.error # => nil
154
+ You can poll until its status change.
116
155
 
117
- # If authorization.verify_status is 'invalid', you can get at the error
118
- # message only through the failed challenge.
119
- authorization.verify_status # => 'invalid'
120
- authorization.http01.error # => {"type" => "...", "detail" => "..."}
156
+ ```ruby
157
+ while challenge.status == 'pending'
158
+ sleep(2)
159
+ challenge.reload
160
+ end
161
+ challenge.status # => 'valid'
121
162
  ```
122
163
 
123
- ### Obtain a certificate
164
+ ### Downloading a certificate
165
+
166
+ Once all required authorizations have been validated through challenges, the order can be finalized using a CSR ([Certificate Signing Request](https://en.wikipedia.org/wiki/Certificate_signing_request)).
167
+
168
+ A CSR can be slightly tricky to generate using OpenSSL from Ruby standard library. `acme-client` provide a utility class `CertificateRequest` to help with that.
124
169
 
125
- Now that your account is authorized for the domain, you should be able to obtain a certificate for it.
170
+ Certificate generation happens asynchronously. You may need to poll.
126
171
 
127
172
  ```ruby
128
- # We're going to need a certificate signing request. If not explicitly
129
- # specified, the first name listed becomes the common name.
130
- csr = Acme::Client::CertificateRequest.new(names: %w[example.org www.example.org])
173
+ csr = Acme::Client::CertificateRequest.new(private_key: private_key, subject: { common_name: 'example.com' })
174
+ order.finalize(csr: csr)
175
+ sleep(1) while order.status == 'processing'
176
+ order.certificate # => PEM-formatted certificate
177
+ ```
178
+
179
+ ## Extra
131
180
 
132
- # We can now request a certificate. You can pass anything that returns
133
- # a valid DER encoded CSR when calling to_der on it. For example an
134
- # OpenSSL::X509::Request should work too.
135
- certificate = client.new_certificate(csr) # => #<Acme::Client::Certificate ....>
181
+ ### Certificate revokation
136
182
 
137
- # Save the certificate and the private key to files
138
- File.write("privkey.pem", certificate.request.private_key.to_pem)
139
- File.write("cert.pem", certificate.to_pem)
140
- File.write("chain.pem", certificate.chain_to_pem)
141
- File.write("fullchain.pem", certificate.fullchain_to_pem)
183
+ To revoke a certificate you can call `#revoke` with the certificate.
142
184
 
143
- # Start a webserver, using your shiny new certificate
144
- # ruby -r openssl -r webrick -r 'webrick/https' -e "s = WEBrick::HTTPServer.new(
145
- # :Port => 8443,
146
- # :DocumentRoot => Dir.pwd,
147
- # :SSLEnable => true,
148
- # :SSLPrivateKey => OpenSSL::PKey::RSA.new( File.read('privkey.pem') ),
149
- # :SSLCertificate => OpenSSL::X509::Certificate.new( File.read('cert.pem') )); trap('INT') { s.shutdown }; s.start"
185
+ ```ruby
186
+ client.revoke(certificate: certificate)
150
187
  ```
151
188
 
152
- # Not implemented
189
+ ### Certificate renewal
153
190
 
154
- - Recovery methods are not implemented.
191
+ The is no renewal process, just create a new order.
155
192
 
156
- # Requirements
193
+
194
+ ## Not implemented
195
+
196
+ - Account Key Roll-over.
197
+
198
+ ## Requirements
157
199
 
158
200
  Ruby >= 2.1
159
201
 
160
202
  ## Development
161
203
 
162
- All the tests use VCR to mock the interaction with the server but if you
163
- need to record new interaction against the server simply clone boulder and
164
- run it normally with `./start.py`.
204
+ All the tests use VCR to mock the interaction with the server. If you need to record new interaction you can specify the directory URL with the `ACME_DIRECTORY_URL` environment variable.
205
+
206
+ ```
207
+ ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory rspec
208
+ ```
165
209
 
166
210
  ## Pull request?
167
211
 
@@ -12,7 +12,6 @@ module Acme; end
12
12
  class Acme::Client; end
13
13
 
14
14
  require 'acme/client/version'
15
- require 'acme/client/certificate'
16
15
  require 'acme/client/certificate_request'
17
16
  require 'acme/client/self_sign_certificate'
18
17
  require 'acme/client/resources'
@@ -22,15 +21,14 @@ require 'acme/client/error'
22
21
  require 'acme/client/util'
23
22
 
24
23
  class Acme::Client
25
- DEFAULT_ENDPOINT = 'http://127.0.0.1:4000'.freeze
26
- DIRECTORY_DEFAULT = {
27
- 'new-authz' => '/acme/new-authz',
28
- 'new-cert' => '/acme/new-cert',
29
- 'new-reg' => '/acme/new-reg',
30
- 'revoke-cert' => '/acme/revoke-cert'
31
- }.freeze
32
-
33
- def initialize(jwk: nil, private_key: nil, endpoint: DEFAULT_ENDPOINT, directory_uri: nil, connection_options: {})
24
+ DEFAULT_DIRECTORY = 'http://127.0.0.1:4000/directory'.freeze
25
+ repo_url = 'https://github.com/unixcharles/acme-client'
26
+ USER_AGENT = "Acme::Client v#{Acme::Client::VERSION} (#{repo_url})".freeze
27
+ CONTENT_TYPES = {
28
+ pem: 'application/pem-certificate-chain'
29
+ }
30
+
31
+ def initialize(jwk: nil, kid: nil, private_key: nil, directory: DEFAULT_DIRECTORY, connection_options: {})
34
32
  if jwk.nil? && private_key.nil?
35
33
  raise ArgumentError, 'must specify jwk or private_key'
36
34
  end
@@ -41,93 +39,255 @@ class Acme::Client
41
39
  Acme::Client::JWK.from_private_key(private_key)
42
40
  end
43
41
 
44
- @endpoint, @directory_uri, @connection_options = endpoint, directory_uri, connection_options
42
+ @kid, @connection_options = kid, connection_options
43
+ @directory = Acme::Client::Resources::Directory.new(URI(directory), @connection_options)
45
44
  @nonces ||= []
46
- load_directory!
47
45
  end
48
46
 
49
- attr_reader :jwk, :nonces, :endpoint, :directory_uri, :operation_endpoints
47
+ attr_reader :jwk, :nonces
50
48
 
51
- def register(contact:)
49
+ def new_account(contact:, terms_of_service_agreed: nil)
52
50
  payload = {
53
- resource: 'new-reg', contact: Array(contact)
51
+ contact: Array(contact)
54
52
  }
55
53
 
56
- response = connection.post(@operation_endpoints.fetch('new-reg'), payload)
57
- ::Acme::Client::Resources::Registration.new(self, response)
54
+ if terms_of_service_agreed
55
+ payload[:termsOfServiceAgreed] = terms_of_service_agreed
56
+ end
57
+
58
+ response = post(endpoint_for(:new_account), payload: payload, mode: :jws)
59
+ @kid = response.headers.fetch(:location)
60
+
61
+ if response.body.nil? || response.body.empty?
62
+ account
63
+ else
64
+ arguments = attributes_from_account_response(response)
65
+ Acme::Client::Resources::Account.new(self, url: @kid, **arguments)
66
+ end
58
67
  end
59
68
 
60
- def authorize(domain:)
61
- payload = {
62
- resource: 'new-authz',
63
- identifier: {
64
- type: 'dns',
65
- value: domain
66
- }
67
- }
69
+ def account_update(contact: nil, terms_of_service_agreed: nil)
70
+ payload = {}
71
+ payload[:contact] = Array(contact) if contact
72
+ payload[:termsOfServiceAgreed] = terms_of_service_agreed if terms_of_service_agreed
68
73
 
69
- response = connection.post(@operation_endpoints.fetch('new-authz'), payload)
70
- ::Acme::Client::Resources::Authorization.new(self, response.headers['Location'], response)
74
+ response = post(kid, payload: payload)
75
+ arguments = attributes_from_account_response(response)
76
+ Acme::Client::Resources::Account.new(self, url: kid, **arguments)
71
77
  end
72
78
 
73
- def fetch_authorization(uri)
74
- response = connection.get(uri)
75
- ::Acme::Client::Resources::Authorization.new(self, uri, response)
79
+ def account_deactivate
80
+ response = post(kid, payload: { status: 'deactivated' })
81
+ arguments = attributes_from_account_response(response)
82
+ Acme::Client::Resources::Account.new(self, url: kid, **arguments)
76
83
  end
77
84
 
78
- def new_certificate(csr)
79
- payload = {
80
- resource: 'new-cert',
81
- csr: Base64.urlsafe_encode64(csr.to_der)
82
- }
85
+ def account
86
+ @kid ||= begin
87
+ response = post(endpoint_for(:new_account), payload: { onlyReturnExisting: true }, mode: :jwk)
88
+ response.headers.fetch(:location)
89
+ end
83
90
 
84
- response = connection.post(@operation_endpoints.fetch('new-cert'), payload)
85
- ::Acme::Client::Certificate.new(OpenSSL::X509::Certificate.new(response.body), response.headers['location'], fetch_chain(response), csr)
91
+ response = post(@kid)
92
+ arguments = attributes_from_account_response(response)
93
+ Acme::Client::Resources::Account.new(self, url: @kid, **arguments)
86
94
  end
87
95
 
88
- def revoke_certificate(certificate)
89
- payload = { resource: 'revoke-cert', certificate: Base64.urlsafe_encode64(certificate.to_der) }
90
- endpoint = @operation_endpoints.fetch('revoke-cert')
91
- response = connection.post(endpoint, payload)
92
- response.success?
96
+ def kid
97
+ @kid ||= account.kid
93
98
  end
94
99
 
95
- def self.revoke_certificate(certificate, *arguments)
96
- client = new(*arguments)
97
- client.revoke_certificate(certificate)
100
+ def new_order(identifiers:, not_before: nil, not_after: nil)
101
+ 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
109
+ payload['notBefore'] = not_before if not_before
110
+ payload['notAfter'] = not_after if not_after
111
+
112
+ response = post(endpoint_for(:new_order), payload: payload)
113
+ arguments = attributes_from_order_response(response)
114
+ Acme::Client::Resources::Order.new(self, **arguments)
98
115
  end
99
116
 
100
- def connection
101
- @connection ||= Faraday.new(@endpoint, **@connection_options) do |configuration|
102
- configuration.use Acme::Client::FaradayMiddleware, client: self
103
- configuration.adapter Faraday.default_adapter
117
+ def order(url:)
118
+ response = get(url)
119
+ arguments = attributes_from_order_response(response)
120
+ Acme::Client::Resources::Order.new(self, **arguments.merge(url: url))
121
+ end
122
+
123
+ def finalize(url:, csr:)
124
+ unless csr.respond_to?(:to_der)
125
+ raise ArgumentError, 'csr must respond to `#to_der`'
126
+ end
127
+
128
+ base64_der_csr = Acme::Client::Util.urlsafe_base64(csr.to_der)
129
+ response = post(url, payload: { csr: base64_der_csr })
130
+ arguments = attributes_from_order_response(response)
131
+ Acme::Client::Resources::Order.new(self, **arguments)
132
+ end
133
+
134
+ def certificate(url:)
135
+ response = download(url, format: :pem)
136
+ response.body
137
+ end
138
+
139
+ def authorization(url:)
140
+ response = get(url)
141
+ arguments = attributes_from_authorization_response(response)
142
+ Acme::Client::Resources::Authorization.new(self, url: url, **arguments)
143
+ end
144
+
145
+ def deactivate_authorization(url:)
146
+ response = post(url, payload: { status: 'deactivated' })
147
+ arguments = attributes_from_authorization_response(response)
148
+ Acme::Client::Resources::Authorization.new(self, url: url, **arguments)
149
+ end
150
+
151
+ def challenge(url:)
152
+ response = get(url)
153
+ arguments = attributes_from_challenge_response(response)
154
+ Acme::Client::Resources::Challenges.new(self, **arguments)
155
+ end
156
+
157
+ def request_challenge_validation(url:, key_authorization:)
158
+ response = post(url, payload: { keyAuthorization: key_authorization })
159
+ arguments = attributes_from_challenge_response(response)
160
+ Acme::Client::Resources::Challenges.new(self, **arguments)
161
+ end
162
+
163
+ def revoke(certificate:, reason: nil)
164
+ der_certificate = if certificate.respond_to?(:to_der)
165
+ certificate.to_der
166
+ else
167
+ OpenSSL::X509::Certificate.new(certificate).to_der
104
168
  end
169
+
170
+ base64_der_certificate = Acme::Client::Util.urlsafe_base64(der_certificate)
171
+ payload = { certificate: base64_der_certificate }
172
+ payload[:reason] = reason unless reason.nil?
173
+
174
+ response = post(endpoint_for(:revoke_certificate), payload: payload)
175
+ response.success?
176
+ end
177
+
178
+ def get_nonce
179
+ response = Faraday.head(endpoint_for(:new_nonce), nil, 'User-Agent' => USER_AGENT)
180
+ nonces << response.headers['replay-nonce']
181
+ true
182
+ end
183
+
184
+ def meta
185
+ @directory.meta
186
+ end
187
+
188
+ def terms_of_service
189
+ @directory.terms_of_service
190
+ end
191
+
192
+ def website
193
+ @directory.website
194
+ end
195
+
196
+ def caa_identities
197
+ @directory.caa_identities
198
+ end
199
+
200
+ def external_account_required
201
+ @directory.external_account_required
105
202
  end
106
203
 
107
204
  private
108
205
 
206
+ def attributes_from_account_response(response)
207
+ extract_attributes(
208
+ response.body,
209
+ :status,
210
+ [:term_of_service, 'termsOfServiceAgreed'],
211
+ :contact
212
+ )
213
+ end
214
+
215
+ def attributes_from_order_response(response)
216
+ attributes = extract_attributes(
217
+ response.body,
218
+ :status,
219
+ :expires,
220
+ [:finalize_url, 'finalize'],
221
+ [:authorization_urls, 'authorizations'],
222
+ [:certificate_url, 'certificate'],
223
+ :identifiers
224
+ )
225
+
226
+ attributes[:url] = response.headers[:location] if response.headers[:location]
227
+ attributes
228
+ end
229
+
230
+ def attributes_from_authorization_response(response)
231
+ extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
232
+ end
233
+
234
+ def attributes_from_challenge_response(response)
235
+ extract_attributes(response.body, :status, :url, :token, :type, :error)
236
+ end
237
+
238
+ def extract_attributes(input, *attributes)
239
+ attributes
240
+ .map {|fields| Array(fields) }
241
+ .each_with_object({}) { |(key, field), hash|
242
+ field ||= key.to_s
243
+ hash[key] = input[field]
244
+ }
245
+ end
246
+
247
+ def post(url, payload: {}, mode: :kid)
248
+ connection = connection_for(url: url, mode: mode)
249
+ connection.post(url, payload)
250
+ end
251
+
252
+ def get(url, mode: :kid)
253
+ connection = connection_for(url: url, mode: mode)
254
+ connection.get(url)
255
+ end
256
+
257
+ def download(url, format:)
258
+ connection = connection_for(url: url, mode: :download)
259
+ connection.get do |request|
260
+ request.url(url)
261
+ request.headers['Accept'] = CONTENT_TYPES.fetch(format)
262
+ end
263
+ end
264
+
265
+ def connection_for(url:, mode:)
266
+ uri = URI(url)
267
+ endpoint = "#{uri.scheme}://#{uri.hostname}:#{uri.port}"
268
+ @connections ||= {}
269
+ @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
+
109
280
  def fetch_chain(response, limit = 10)
110
281
  links = response.headers['link']
111
282
  if limit.zero? || links.nil? || links['up'].nil?
112
283
  []
113
284
  else
114
- issuer = connection.get(links['up'])
285
+ issuer = get(links['up'])
115
286
  [OpenSSL::X509::Certificate.new(issuer.body), *fetch_chain(issuer, limit - 1)]
116
287
  end
117
288
  end
118
289
 
119
- def load_directory!
120
- @operation_endpoints = if @directory_uri
121
- response = connection.get(@directory_uri)
122
- body = response.body
123
- {
124
- 'new-reg' => body.fetch('new-reg'),
125
- 'new-authz' => body.fetch('new-authz'),
126
- 'new-cert' => body.fetch('new-cert'),
127
- 'revoke-cert' => body.fetch('revoke-cert')
128
- }
129
- else
130
- DIRECTORY_DEFAULT
131
- end
290
+ def endpoint_for(key)
291
+ @directory.endpoint_for(key)
132
292
  end
133
293
  end