letscert 0.4.1 → 0.4.2
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
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/.rubocop.yml +33 -0
- data/Gemfile +2 -0
- data/README.md +6 -3
- data/Rakefile +4 -1
- data/letscert.gemspec +5 -5
- data/lib/letscert/certificate.rb +104 -82
- data/lib/letscert/io_plugin.rb +32 -350
- data/lib/letscert/io_plugins/account_key.rb +29 -0
- data/lib/letscert/io_plugins/cert_file.rb +29 -0
- data/lib/letscert/io_plugins/chain_file.rb +32 -0
- data/lib/letscert/io_plugins/file_io_plugin_mixin.rb +48 -0
- data/lib/letscert/io_plugins/full_chain_file.rb +39 -0
- data/lib/letscert/io_plugins/jwk_io_plugin_mixin.rb +68 -0
- data/lib/letscert/io_plugins/key_file.rb +29 -0
- data/lib/letscert/io_plugins/openssl_io_plugin.rb +68 -0
- data/lib/letscert/loggable.rb +2 -3
- data/lib/letscert/runner.rb +130 -157
- data/lib/letscert/runner/logger_formatter.rb +34 -0
- data/lib/letscert/runner/valid_time.rb +48 -0
- data/lib/letscert/version.rb +1 -1
- metadata +13 -16
- metadata.gz.sig +1 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 81459818c78f3836b106c632ae5befa99dcacf82
|
4
|
+
data.tar.gz: 1fce468a7511a75b84f584ff1fd8b5455db48065
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3b4ce524f540fbb291c6c88f13d32bfa27a501c2105d8c5002ff98ef9d4c808802cdaecd7cbffad6e53695792b84ed180517d16facd079af245f960f9cce100a
|
7
|
+
data.tar.gz: 2149c9cd3a620ecebf9d1ec2bdaaeef2f7f92944127c25f4351ecc42424503526c9fbf2d8498ad19b54277f76b301fe3a838eb96a3a7562745f69e08c60c4077
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data.tar.gz.sig
CHANGED
Binary file
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
Style/AndOr:
|
2
|
+
Enabled: false
|
3
|
+
|
4
|
+
Style/EmptyLinesAroundClassBody:
|
5
|
+
Enabled: false
|
6
|
+
|
7
|
+
Style/EmptyLinesAroundModuleBody:
|
8
|
+
Enabled: false
|
9
|
+
|
10
|
+
Style/Documentation:
|
11
|
+
Enabled: false
|
12
|
+
|
13
|
+
Style/FormatString:
|
14
|
+
Enabled: false
|
15
|
+
|
16
|
+
Metrics/ClassLength:
|
17
|
+
Max: 150
|
18
|
+
|
19
|
+
Metrics/ModuleLength:
|
20
|
+
Max: 150
|
21
|
+
|
22
|
+
Metrics/MethodLength:
|
23
|
+
Max: 20
|
24
|
+
|
25
|
+
# don't understand this metric
|
26
|
+
Metrics/AbcSize:
|
27
|
+
Enabled: false
|
28
|
+
|
29
|
+
AllCops:
|
30
|
+
Exclude:
|
31
|
+
- 'spec/**/*'
|
32
|
+
- 'vendor/**/*'
|
33
|
+
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -75,14 +75,17 @@ letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld --r
|
|
75
75
|
* 2 in case of errors.
|
76
76
|
|
77
77
|
# Installation
|
78
|
-
`letscert` is cryptographically signed. To be sure the gem you install
|
78
|
+
Since v0.4.1, `letscert` is cryptographically signed. To be sure the gem you install
|
79
|
+
hasn’t been tampered:
|
79
80
|
* add my public key as a trusted certificate:
|
80
81
|
```
|
81
|
-
gem cert --add <(curl -Ls https://raw.github.com/
|
82
|
+
gem cert --add <(curl -Ls https://raw.github.com/sdaubert/letscert/master/certs/gem-public_cert.pem)
|
82
83
|
```
|
83
84
|
* install letscert gem with a policy:
|
84
85
|
```
|
85
86
|
gem install letscert -P MediumSecurity
|
86
87
|
```
|
87
88
|
|
88
|
-
The MediumSecurity trust profile will verify signed gems, but allow the installation of
|
89
|
+
The MediumSecurity trust profile will verify signed gems, but allow the installation of
|
90
|
+
unsigned dependencies. This is necessary because not all of letcert’s dependencies are
|
91
|
+
signed, so we cannot use HighSecurity.
|
data/Rakefile
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'bundler/gem_tasks'
|
2
2
|
require 'rspec/core/rake_task'
|
3
3
|
require 'yard'
|
4
|
+
require 'rubocop/rake_task'
|
4
5
|
|
5
6
|
RSpec::Core::RakeTask.new
|
6
7
|
|
@@ -9,4 +10,6 @@ YARD::Rake::YardocTask.new do |t|
|
|
9
10
|
t.files = ['lib/**/*.rb', '-', 'LICENSE']
|
10
11
|
end
|
11
12
|
|
12
|
-
|
13
|
+
RuboCop::RakeTask.new
|
14
|
+
|
15
|
+
task default: :spec
|
data/letscert.gemspec
CHANGED
@@ -16,17 +16,17 @@ EOF
|
|
16
16
|
s.email = 'sylvain.daubert@laposte.net'
|
17
17
|
s.homepage = 'https://github.com/sdaubert/letscert'
|
18
18
|
|
19
|
-
|
19
|
+
files = `git ls-files -z`.split("\x0")
|
20
|
+
s.files = files.reject { |f| f.match(%r{^(test|spec|features)/}) }
|
20
21
|
s.executables = ['letscert']
|
21
|
-
s.require_paths = [
|
22
|
+
s.require_paths = ['lib']
|
22
23
|
|
23
24
|
s.required_ruby_version = '>= 2.1.0'
|
24
25
|
|
25
26
|
s.add_dependency 'acme-client', '~>0.4.0'
|
26
|
-
s.add_dependency 'json', '~>1.8.3'
|
27
27
|
|
28
|
-
s.add_development_dependency
|
29
|
-
s.add_development_dependency
|
28
|
+
s.add_development_dependency 'bundler', '~> 1.12'
|
29
|
+
s.add_development_dependency 'rake', '~> 10.0'
|
30
30
|
s.add_development_dependency 'rspec', '~>3.4'
|
31
31
|
s.add_development_dependency 'vcr', '~>3.0'
|
32
32
|
s.add_development_dependency 'yard', '~>0.8'
|
data/lib/letscert/certificate.rb
CHANGED
@@ -37,7 +37,6 @@ module LetsCert
|
|
37
37
|
# @return [Acme::Client,nil]
|
38
38
|
attr_reader :client
|
39
39
|
|
40
|
-
|
41
40
|
# @param [OpenSSL::X509::Certificate,nil] cert
|
42
41
|
def initialize(cert)
|
43
42
|
@cert = cert
|
@@ -45,26 +44,28 @@ module LetsCert
|
|
45
44
|
end
|
46
45
|
|
47
46
|
# Get a new certificate, or renew an existing one
|
48
|
-
# @param [OpenSSL::PKey::PKey,nil] account_key private key to
|
49
|
-
# server
|
50
|
-
# @param [OpenSSL::PKey::PKey, nil] key private key from which make a
|
51
|
-
#
|
47
|
+
# @param [OpenSSL::PKey::PKey,nil] account_key private key to
|
48
|
+
# authenticate to ACME server
|
49
|
+
# @param [OpenSSL::PKey::PKey, nil] key private key from which make a
|
50
|
+
# certificate. If +nil+, generate a new one with +options[:cet_key_size]+
|
51
|
+
# bits.
|
52
52
|
# @param [Hash] options option hash
|
53
|
-
# @option options [Fixnum] :account_key_size ACME account private key size
|
53
|
+
# @option options [Fixnum] :account_key_size ACME account private key size
|
54
|
+
# in bits
|
54
55
|
# @option options [Fixnum] :cert_key_size private key size used to generate
|
55
56
|
# a certificate
|
56
57
|
# @option options [String] :email e-mail used as ACME account
|
57
58
|
# @option options [Array<String>] :files plugin names to use
|
58
59
|
# @option options [Boolean] :reuse_key reuse private key when getting a new
|
59
60
|
# certificate
|
60
|
-
# @option options [Hash] :roots hash associating domains as keys to web
|
61
|
-
#
|
61
|
+
# @option options [Hash] :roots hash associating domains as keys to web
|
62
|
+
# roots as values
|
62
63
|
# @option options [String] :server ACME servel URL
|
63
64
|
# @return [void]
|
64
65
|
# @raise [Acme::Client::Error] error in protocol ACME with server
|
65
66
|
# @raise [Error] issue with domain name, challenge fails,...
|
66
67
|
def get(account_key, key, options)
|
67
|
-
logger.info {
|
68
|
+
logger.info { 'create key/cert/chain...' }
|
68
69
|
check_roots(options[:roots])
|
69
70
|
logger.debug { "webroots are: #{options[:roots].inspect}" }
|
70
71
|
|
@@ -72,23 +73,20 @@ module LetsCert
|
|
72
73
|
|
73
74
|
do_challenges client, options[:roots]
|
74
75
|
|
75
|
-
if options[:reuse_key]
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
acme_cert = client.new_certificate(csr)
|
85
|
-
@cert = acme_cert.x509
|
86
|
-
@chain = acme_cert.x509_chain
|
76
|
+
pkey = if options[:reuse_key]
|
77
|
+
raise Error, 'cannot reuse a non-existing key' if key.nil?
|
78
|
+
logger.info { 'Reuse existing private key' }
|
79
|
+
generate_certificate_from_pkey options[:roots].keys, key
|
80
|
+
else
|
81
|
+
logger.info { 'Generate new private key' }
|
82
|
+
generate_certificate options[:roots].keys,
|
83
|
+
options[:cert_key_size]
|
84
|
+
end
|
87
85
|
|
88
86
|
options[:files] ||= []
|
89
87
|
options[:files].each do |plugname|
|
90
88
|
IOPlugin.registered[plugname].save(account_key: client.private_key,
|
91
|
-
key:
|
89
|
+
key: pkey, cert: @cert,
|
92
90
|
chain: @chain)
|
93
91
|
end
|
94
92
|
end
|
@@ -96,22 +94,17 @@ module LetsCert
|
|
96
94
|
# Revoke certificate
|
97
95
|
# @param [OpenSSL::PKey::PKey] account_key
|
98
96
|
# @param [Hash] options
|
99
|
-
# @option options [Fixnum] :account_key_size ACME account private key size
|
97
|
+
# @option options [Fixnum] :account_key_size ACME account private key size
|
98
|
+
# in bits
|
100
99
|
# @option options [String] :email e-mail used as ACME account
|
101
100
|
# @option options [String] :server ACME servel URL
|
102
101
|
# @return [Boolean]
|
103
102
|
# @raise [Error] no certificate to revole.
|
104
|
-
def revoke(account_key, options={})
|
105
|
-
if @cert.nil?
|
106
|
-
raise Error, 'no certification data to revoke'
|
107
|
-
end
|
103
|
+
def revoke(account_key, options = {})
|
104
|
+
raise Error, 'no certification data to revoke' if @cert.nil?
|
108
105
|
|
109
106
|
client = get_acme_client(account_key, options)
|
110
|
-
|
111
|
-
result = client.revoke_certificate(@cert)
|
112
|
-
rescue Exception => ex
|
113
|
-
raise
|
114
|
-
end
|
107
|
+
result = client.revoke_certificate(@cert)
|
115
108
|
|
116
109
|
if result
|
117
110
|
logger.info { 'certificate is revoked' }
|
@@ -125,10 +118,10 @@ module LetsCert
|
|
125
118
|
# Check if certificate is still valid for at least +valid_min+ seconds.
|
126
119
|
# Also checks that +domains+ are certified by certificate.
|
127
120
|
# @param [Array<String>] domains list of certificate domains
|
128
|
-
# @param [Integer] valid_min minimum number of seconds of validity under
|
129
|
-
#
|
121
|
+
# @param [Integer] valid_min minimum number of seconds of validity under
|
122
|
+
# which a renewal is necessary.
|
130
123
|
# @return [Boolean]
|
131
|
-
def valid?(domains, valid_min=0)
|
124
|
+
def valid?(domains, valid_min = 0)
|
132
125
|
if @cert.nil?
|
133
126
|
logger.debug { 'no existing certificate' }
|
134
127
|
return false
|
@@ -144,8 +137,9 @@ module LetsCert
|
|
144
137
|
|
145
138
|
# Check all domains are subjects of certificate
|
146
139
|
unless domains.all? { |domain| subjects.include? domain }
|
147
|
-
|
148
|
-
|
140
|
+
msg = 'At least one domain is not declared as a certificate subject. ' \
|
141
|
+
'Backup and remove existing cert if you want to proceed.'
|
142
|
+
raise Error, msg
|
149
143
|
end
|
150
144
|
|
151
145
|
!renewal_necessary?(valid_min)
|
@@ -168,20 +162,18 @@ module LetsCert
|
|
168
162
|
yield @client if block_given?
|
169
163
|
|
170
164
|
if options[:email].nil?
|
171
|
-
logger.warn { '--email was not provided. ACME CA will have no way to '
|
172
|
-
|
165
|
+
logger.warn { '--email was not provided. ACME CA will have no way to ' \
|
166
|
+
'contact you!' }
|
173
167
|
end
|
174
168
|
|
175
169
|
begin
|
176
170
|
logger.debug { "register with #{options[:email]}" }
|
177
171
|
registration = @client.register(contact: "mailto:#{options[:email]}")
|
178
172
|
rescue Acme::Client::Error::Malformed => ex
|
179
|
-
if ex.message != 'Registration key is already in use'
|
180
|
-
raise
|
181
|
-
end
|
173
|
+
raise if ex.message != 'Registration key is already in use'
|
182
174
|
else
|
183
|
-
# Requesting ToS make acme-client throw an exception: Connection reset
|
184
|
-
# (Faraday::ConnectionFailed). To investigate...
|
175
|
+
# Requesting ToS make acme-client throw an exception: Connection reset
|
176
|
+
# by peer (Faraday::ConnectionFailed). To investigate...
|
185
177
|
#if registration.term_of_service_uri
|
186
178
|
# @logger.debug { "get terms of service" }
|
187
179
|
# terms = registration.get_terms
|
@@ -190,8 +182,7 @@ module LetsCert
|
|
190
182
|
# if tos_digest != @options[:tos_sha256]
|
191
183
|
# raise Error, 'Terms Of Service mismatch'
|
192
184
|
# end
|
193
|
-
|
194
|
-
@logger.debug { "agree terms of service" }
|
185
|
+
@logger.debug { 'agree terms of service' }
|
195
186
|
registration.agree_terms
|
196
187
|
# end
|
197
188
|
#end
|
@@ -200,19 +191,18 @@ module LetsCert
|
|
200
191
|
@client
|
201
192
|
end
|
202
193
|
|
203
|
-
|
204
194
|
private
|
205
195
|
|
206
196
|
# check webroots.
|
207
197
|
# @param [Hash] roots
|
208
198
|
# @raise [Error] if some domains have no defined root.
|
209
199
|
def check_roots(roots)
|
210
|
-
no_roots = roots.select { |
|
200
|
+
no_roots = roots.select { |_k, v| v.nil? }
|
211
201
|
|
212
|
-
|
213
|
-
raise Error, 'root for the following domain(s) are not specified: '
|
214
|
-
no_roots.keys.join(', ')
|
215
|
-
'-d example.com:/var/www/html syntax.'
|
202
|
+
unless no_roots.empty?
|
203
|
+
raise Error, 'root for the following domain(s) are not specified: ' \
|
204
|
+
"#{no_roots.keys.join(', ')}.\nTry --default_root or " \
|
205
|
+
'use -d example.com:/var/www/html syntax.'
|
216
206
|
end
|
217
207
|
end
|
218
208
|
|
@@ -234,26 +224,12 @@ module LetsCert
|
|
234
224
|
# @param [Hash] roots
|
235
225
|
def do_challenges(client, roots)
|
236
226
|
logger.debug { 'Get authorization for all domains' }
|
237
|
-
challenges =
|
238
|
-
|
239
|
-
roots.keys.each do |domain|
|
240
|
-
authorization = client.authorize(domain: domain)
|
241
|
-
if authorization
|
242
|
-
challenges[domain] = authorization.http01
|
243
|
-
else
|
244
|
-
challenges[domain] = nil
|
245
|
-
end
|
246
|
-
end
|
247
|
-
|
248
|
-
logger.debug { 'Check all challenges are HTTP-01' }
|
249
|
-
if challenges.values.any? { |chall| chall.nil? }
|
250
|
-
raise Error, 'CA did not offer http-01-only challenge. ' +
|
251
|
-
'This client is unable to solve any other challenges.'
|
252
|
-
end
|
227
|
+
challenges = get_challenges(client, roots)
|
253
228
|
|
254
229
|
challenges.each do |domain, challenge|
|
255
230
|
begin
|
256
|
-
|
231
|
+
path = File.join(roots[domain], File.dirname(challenge.filename))
|
232
|
+
FileUtils.mkdir_p path
|
257
233
|
rescue SystemCallError => ex
|
258
234
|
raise Error, ex.message
|
259
235
|
end
|
@@ -263,20 +239,44 @@ module LetsCert
|
|
263
239
|
File.write path, challenge.file_content
|
264
240
|
|
265
241
|
challenge.request_verification
|
242
|
+
wait_for_verification challenge, domain
|
266
243
|
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
status = challenge.verify_status
|
271
|
-
end
|
244
|
+
File.unlink path
|
245
|
+
end
|
246
|
+
end
|
272
247
|
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
248
|
+
# Get challenges
|
249
|
+
# @param [Acme::Client] client
|
250
|
+
# @param [Hash] roots
|
251
|
+
# @return [Hash] key: domain, value: authorization
|
252
|
+
# @raise [Error] if any challenges does not support HTTP-01
|
253
|
+
def get_challenges(client, roots)
|
254
|
+
challenges = {}
|
255
|
+
roots.keys.each do |domain|
|
256
|
+
authorization = client.authorize(domain: domain)
|
257
|
+
challenges[domain] = authorization ? authorization.http01 : nil
|
258
|
+
end
|
278
259
|
|
279
|
-
|
260
|
+
logger.debug { 'Check all challenges are HTTP-01' }
|
261
|
+
if challenges.values.any?(&:nil?)
|
262
|
+
raise Error, 'CA did not offer http-01-only challenge. ' \
|
263
|
+
'This client is unable to solve any other challenges.'
|
264
|
+
end
|
265
|
+
|
266
|
+
challenges
|
267
|
+
end
|
268
|
+
|
269
|
+
def wait_for_verification(challenge, domain)
|
270
|
+
status = 'pending'
|
271
|
+
while status == 'pending'
|
272
|
+
sleep(1)
|
273
|
+
status = challenge.verify_status
|
274
|
+
end
|
275
|
+
|
276
|
+
if status != 'valid'
|
277
|
+
logger.warn { "#{domain} was not successfully verified!" }
|
278
|
+
else
|
279
|
+
logger.info { "#{domain} was successfully verified." }
|
280
280
|
end
|
281
281
|
end
|
282
282
|
|
@@ -286,12 +286,34 @@ module LetsCert
|
|
286
286
|
def renewal_necessary?(valid_min)
|
287
287
|
now = Time.now.utc
|
288
288
|
diff = (@cert.not_after - now).to_i
|
289
|
-
logger.debug { "Certificate expires in #{diff}s on #{@cert.not_after}"
|
289
|
+
logger.debug { "Certificate expires in #{diff}s on #{@cert.not_after}" \
|
290
290
|
" (relative to #{now})" }
|
291
291
|
|
292
292
|
diff < valid_min
|
293
293
|
end
|
294
294
|
|
295
|
-
|
295
|
+
# Generate new certificate for given domains with existing private key
|
296
|
+
# @param [Array<String>] domains
|
297
|
+
# @param [OpenSSL::PKey::PKey] pkey private key to use
|
298
|
+
# @return [OpenSSL::PKey::PKey] +pkey+
|
299
|
+
def generate_certificate_from_pkey(domains, pkey)
|
300
|
+
csr = Acme::Client::CertificateRequest.new(names: domains,
|
301
|
+
private_key: pkey)
|
302
|
+
acme_cert = client.new_certificate(csr)
|
303
|
+
@cert = acme_cert.x509
|
304
|
+
@chain = acme_cert.x509_chain
|
305
|
+
|
306
|
+
pkey
|
307
|
+
end
|
296
308
|
|
309
|
+
# Generate new certificate for given domains
|
310
|
+
# @param [Array<String>] domains
|
311
|
+
# @param [Integer] pkey_size size in bits for private key to generate
|
312
|
+
# @return [OpenSSL::PKey::PKey] generated private key
|
313
|
+
def generate_certificate(domains, pkey_size)
|
314
|
+
pkey = OpenSSL::PKey::RSA.generate(pkey_size)
|
315
|
+
generate_certificate_from_pkey domains, pkey
|
316
|
+
end
|
317
|
+
|
318
|
+
end
|
297
319
|
end
|
data/lib/letscert/io_plugin.rb
CHANGED
@@ -19,7 +19,6 @@
|
|
19
19
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
20
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
21
|
# SOFTWARE.
|
22
|
-
require 'base64'
|
23
22
|
require_relative 'loggable'
|
24
23
|
|
25
24
|
module LetsCert
|
@@ -33,35 +32,36 @@ module LetsCert
|
|
33
32
|
# @return [String]
|
34
33
|
attr_reader :name
|
35
34
|
|
36
|
-
|
37
35
|
# Registered plugins
|
38
|
-
|
36
|
+
@registered = {}
|
39
37
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
38
|
+
class << self
|
39
|
+
|
40
|
+
# Get registered plugins
|
41
|
+
# @return [Hash] keys are filenames and keys are instances of IOPlugin
|
42
|
+
# subclasses.
|
43
|
+
attr_reader :registered
|
45
44
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
def self.register(klass, *args)
|
51
|
-
plugin = klass.new(*args)
|
52
|
-
if plugin.name =~ /[\/\\]/ or ['.', '..'].include?(plugin.name)
|
53
|
-
raise Error, "plugin name should just be a file name, without path"
|
45
|
+
# Get empty data
|
46
|
+
# @return [Hash] +{ account_key: nil, key: nil, cert: nil, chain: nil }+
|
47
|
+
def empty_data
|
48
|
+
{ account_key: nil, key: nil, cert: nil, chain: nil }
|
54
49
|
end
|
55
50
|
|
56
|
-
|
51
|
+
# Register a plugin
|
52
|
+
# @param [Class] klass
|
53
|
+
# @param [Array] args args to pass to +klass+ constructor
|
54
|
+
# @return [IOPlugin]
|
55
|
+
def register(klass, *args)
|
56
|
+
plugin = klass.new(*args)
|
57
|
+
if plugin.name =~ %r{[/\\]} or ['.', '..'].include?(plugin.name)
|
58
|
+
raise Error, 'plugin name should just be a file name, without path'
|
59
|
+
end
|
57
60
|
|
58
|
-
|
59
|
-
|
61
|
+
@registered[plugin.name] = plugin
|
62
|
+
klass
|
63
|
+
end
|
60
64
|
|
61
|
-
# Get registered plugins
|
62
|
-
# @return [Hash] keys are filenames and keys are instances of IOPlugin subclasses.
|
63
|
-
def self.registered
|
64
|
-
@@registered
|
65
65
|
end
|
66
66
|
|
67
67
|
# @param [String] name
|
@@ -81,331 +81,13 @@ module LetsCert
|
|
81
81
|
|
82
82
|
end
|
83
83
|
|
84
|
-
|
85
|
-
# Mixin for IOPmugin subclasses that handle files
|
86
|
-
# @author Sylvain Daubert
|
87
|
-
module FileIOPluginMixin
|
88
|
-
|
89
|
-
# Load data from file named +#name+
|
90
|
-
# @return [Hash]
|
91
|
-
def load
|
92
|
-
logger.debug { "Loading #@name" }
|
93
|
-
|
94
|
-
begin
|
95
|
-
content = File.read(@name)
|
96
|
-
rescue SystemCallError => ex
|
97
|
-
if ex.is_a? Errno::ENOENT
|
98
|
-
logger.info { "no #@name file" }
|
99
|
-
return self.class.empty_data
|
100
|
-
end
|
101
|
-
raise
|
102
|
-
end
|
103
|
-
|
104
|
-
load_from_content(content)
|
105
|
-
end
|
106
|
-
|
107
|
-
# @abstract
|
108
|
-
# @param [String] content
|
109
|
-
# @return [Hash]
|
110
|
-
def load_from_content(content)
|
111
|
-
raise NotImplementedError
|
112
|
-
end
|
113
|
-
|
114
|
-
# Save data to file +#name+
|
115
|
-
# @param [Hash] data
|
116
|
-
# @return [void]
|
117
|
-
def save_to_file(data)
|
118
|
-
return if data.nil?
|
119
|
-
|
120
|
-
logger.info { "saving #@name" }
|
121
|
-
begin
|
122
|
-
File.open(name, 'w') do |f|
|
123
|
-
f.write(data)
|
124
|
-
end
|
125
|
-
rescue Errno => ex
|
126
|
-
@logger.error { ex.message }
|
127
|
-
raise Error, "Error when saving #@name"
|
128
|
-
end
|
129
|
-
end
|
130
|
-
|
131
|
-
end
|
132
|
-
|
133
|
-
|
134
|
-
# Mixin for IOPlugin subclasses that handle JWK
|
135
|
-
# @author Sylvain Daubert
|
136
|
-
module JWKIOPluginMixin
|
137
|
-
|
138
|
-
# Encode string +data+ to base64
|
139
|
-
# @param [String] data
|
140
|
-
# @return [String]
|
141
|
-
def urlsafe_encode64(data)
|
142
|
-
Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
|
143
|
-
end
|
144
|
-
|
145
|
-
# Decode base64 string +data+
|
146
|
-
# @param [String] data
|
147
|
-
# @return [String]
|
148
|
-
def urlsafe_decode64(data)
|
149
|
-
Base64.urlsafe_decode64(data.sub(/[\s=]*\z/, ''))
|
150
|
-
end
|
151
|
-
|
152
|
-
# Load crypto data from JSON-encoded file
|
153
|
-
# @param [String] data JSON-encoded data
|
154
|
-
# @return [OpenSSL::PKey::PKey]
|
155
|
-
def load_jwk(data)
|
156
|
-
return nil if data.empty?
|
157
|
-
|
158
|
-
h = JSON.parse(data)
|
159
|
-
case h['kty']
|
160
|
-
when 'RSA'
|
161
|
-
pkey = OpenSSL::PKey::RSA.new
|
162
|
-
%w(e n d p q).collect do |key|
|
163
|
-
next if h[key].nil?
|
164
|
-
value = OpenSSL::BN.new(urlsafe_decode64(h[key]), 2)
|
165
|
-
pkey.send "#{key}=".to_sym, value
|
166
|
-
end
|
167
|
-
else
|
168
|
-
raise Error, "unknown account key type '#{k['kty']}'"
|
169
|
-
end
|
170
|
-
|
171
|
-
pkey
|
172
|
-
end
|
173
|
-
|
174
|
-
# Dump crypto data (key) to a JSON-encoded string
|
175
|
-
# @param [OpenSSL::PKey] key
|
176
|
-
# @return [String]
|
177
|
-
def dump_jwk(key)
|
178
|
-
return {}.to_json if key.nil?
|
179
|
-
|
180
|
-
h = { 'kty' => 'RSA' }
|
181
|
-
case key
|
182
|
-
when OpenSSL::PKey::RSA
|
183
|
-
h['e'] = urlsafe_encode64(key.e.to_s(2)) if key.e
|
184
|
-
h['n'] = urlsafe_encode64(key.n.to_s(2)) if key.n
|
185
|
-
if key.private?
|
186
|
-
h['d'] = urlsafe_encode64(key.d.to_s(2))
|
187
|
-
h['p'] = urlsafe_encode64(key.p.to_s(2))
|
188
|
-
h['q'] = urlsafe_encode64(key.q.to_s(2))
|
189
|
-
end
|
190
|
-
else
|
191
|
-
raise Error, "only RSA keys are supported"
|
192
|
-
end
|
193
|
-
h.to_json
|
194
|
-
end
|
195
|
-
end
|
196
|
-
|
197
|
-
|
198
|
-
# Account key IO plugin
|
199
|
-
# @author Sylvain Daubert
|
200
|
-
class AccountKey < IOPlugin
|
201
|
-
include FileIOPluginMixin
|
202
|
-
include JWKIOPluginMixin
|
203
|
-
|
204
|
-
# @return [Hash] always get +true+ for +:account_key+ key
|
205
|
-
def persisted
|
206
|
-
{ account_key: true }
|
207
|
-
end
|
208
|
-
|
209
|
-
# @return [Hash]
|
210
|
-
def load_from_content(content)
|
211
|
-
{ account_key: load_jwk(content) }
|
212
|
-
end
|
213
|
-
|
214
|
-
# Save account key.
|
215
|
-
# @param [Hash] data
|
216
|
-
# @return [void]
|
217
|
-
def save(data)
|
218
|
-
save_to_file(dump_jwk(data[:account_key]))
|
219
|
-
end
|
220
|
-
|
221
|
-
end
|
222
|
-
IOPlugin.register(AccountKey, 'account_key.json')
|
223
|
-
|
224
|
-
|
225
|
-
# OpenSSL IOPlugin
|
226
|
-
# @author Sylvain Daubert
|
227
|
-
class OpenSSLIOPlugin < IOPlugin
|
228
|
-
|
229
|
-
# @private Regular expression to discriminate PEM
|
230
|
-
PEM_RE = /^-----BEGIN CERTIFICATE-----\n.*?\n-----END CERTIFICATE-----\n/m
|
231
|
-
|
232
|
-
# @param [String] name filename
|
233
|
-
# @param [:pem,:der] type
|
234
|
-
def initialize(name, type)
|
235
|
-
case type
|
236
|
-
when :pem
|
237
|
-
when :der
|
238
|
-
else
|
239
|
-
raise ArgumentError, "type should be :pem or :der"
|
240
|
-
end
|
241
|
-
|
242
|
-
@type = type
|
243
|
-
super(name)
|
244
|
-
end
|
245
|
-
|
246
|
-
# Load key from raw +data+
|
247
|
-
# @param [String] data
|
248
|
-
# @return [OpenSSL::PKey]
|
249
|
-
def load_key(data)
|
250
|
-
OpenSSL::PKey::RSA.new data
|
251
|
-
end
|
252
|
-
|
253
|
-
# Dump key/cert data
|
254
|
-
# @param [OpenSSL::PKey] key
|
255
|
-
# @return [String]
|
256
|
-
def dump_key(key)
|
257
|
-
case @type
|
258
|
-
when :pem
|
259
|
-
key.to_pem
|
260
|
-
when :der
|
261
|
-
key.to_der
|
262
|
-
end
|
263
|
-
end
|
264
|
-
alias :dump_cert :dump_key
|
265
|
-
|
266
|
-
# Load certificate from raw +data+
|
267
|
-
# @param [String] data
|
268
|
-
# @return [OpenSSL::X509::Certificate]
|
269
|
-
def load_cert(data)
|
270
|
-
OpenSSL::X509::Certificate.new data
|
271
|
-
end
|
272
|
-
|
273
|
-
|
274
|
-
private
|
275
|
-
|
276
|
-
# Split concatenated PEMs.
|
277
|
-
# @param [String] data
|
278
|
-
# @yield [String] pem
|
279
|
-
def split_pems(data)
|
280
|
-
my_data = data
|
281
|
-
m = my_data.match(PEM_RE)
|
282
|
-
while (m) do
|
283
|
-
yield m[0]
|
284
|
-
my_data = my_data[m.end(0)..-1]
|
285
|
-
m = my_data.match(PEM_RE)
|
286
|
-
end
|
287
|
-
end
|
288
|
-
|
289
|
-
end
|
290
|
-
|
291
|
-
|
292
|
-
# Key file plugin
|
293
|
-
# @author Sylvain Daubert
|
294
|
-
class KeyFile < OpenSSLIOPlugin
|
295
|
-
include FileIOPluginMixin
|
296
|
-
|
297
|
-
# @return [Hash] always get +true+ for +:key+ key
|
298
|
-
def persisted
|
299
|
-
@persisted ||= { key: true }
|
300
|
-
end
|
301
|
-
|
302
|
-
# @return [Hash]
|
303
|
-
def load_from_content(content)
|
304
|
-
{ key: load_key(content) }
|
305
|
-
end
|
306
|
-
|
307
|
-
# Save private key.
|
308
|
-
# @param [Hash] data
|
309
|
-
# @return [void]
|
310
|
-
def save(data)
|
311
|
-
save_to_file(dump_key(data[:key]))
|
312
|
-
end
|
313
|
-
|
314
|
-
end
|
315
|
-
IOPlugin.register(KeyFile, 'key.pem', :pem)
|
316
|
-
IOPlugin.register(KeyFile, 'key.der', :der)
|
317
|
-
|
318
|
-
|
319
|
-
# Chain file plugin
|
320
|
-
# @author Sylvain Daubert
|
321
|
-
class ChainFile < OpenSSLIOPlugin
|
322
|
-
include FileIOPluginMixin
|
323
|
-
|
324
|
-
# @return [Hash] always get +true+ for +:chain+ key
|
325
|
-
def persisted
|
326
|
-
@persisted ||= { chain: true }
|
327
|
-
end
|
328
|
-
|
329
|
-
# @return [Hash]
|
330
|
-
def load_from_content(content)
|
331
|
-
chain = []
|
332
|
-
split_pems(content) do |pem|
|
333
|
-
chain << load_cert(pem)
|
334
|
-
end
|
335
|
-
{ chain: chain }
|
336
|
-
end
|
337
|
-
|
338
|
-
# Save chain.
|
339
|
-
# @param [Hash] data
|
340
|
-
# @return [void]
|
341
|
-
def save(data)
|
342
|
-
save_to_file(data[:chain].map { |c| dump_cert(c) }.join)
|
343
|
-
end
|
344
|
-
|
345
|
-
end
|
346
|
-
IOPlugin.register(ChainFile, 'chain.pem', :pem)
|
347
|
-
|
348
|
-
|
349
|
-
# Fullchain file plugin
|
350
|
-
# @author Sylvain Daubert
|
351
|
-
class FullChainFile < ChainFile
|
352
|
-
|
353
|
-
# @return [Hash] always get +true+ for +:cert+ and +:chain+ keys
|
354
|
-
def persisted
|
355
|
-
@persisted ||= { cert: true, chain: true }
|
356
|
-
end
|
357
|
-
|
358
|
-
# Load full certificate chain
|
359
|
-
# @return [Hash]
|
360
|
-
def load
|
361
|
-
data = super
|
362
|
-
if data[:chain].nil? or data[:chain].empty?
|
363
|
-
cert = nil
|
364
|
-
chain = []
|
365
|
-
else
|
366
|
-
cert = data[:chain].shift
|
367
|
-
chain = data[:chain]
|
368
|
-
end
|
369
|
-
|
370
|
-
{ account_key: data[:account_key], key: data[:key], cert: cert, chain: chain }
|
371
|
-
end
|
372
|
-
|
373
|
-
# Save fullchain.
|
374
|
-
# @param [Hash] data
|
375
|
-
# @return [void]
|
376
|
-
def save(data)
|
377
|
-
super(account_key: data[:account_key], key: data[:key], cert: nil,
|
378
|
-
chain: [data[:cert]] + data[:chain])
|
379
|
-
end
|
380
|
-
|
381
|
-
end
|
382
|
-
IOPlugin.register(FullChainFile, 'fullchain.pem', :pem)
|
383
|
-
|
384
|
-
|
385
|
-
# Cert file plugin
|
386
|
-
# @author Sylvain Daubert
|
387
|
-
class CertFile < OpenSSLIOPlugin
|
388
|
-
include FileIOPluginMixin
|
389
|
-
|
390
|
-
# @return [Hash] always get +true+ for +:cert+ key
|
391
|
-
def persisted
|
392
|
-
@persisted ||= { cert: true }
|
393
|
-
end
|
394
|
-
|
395
|
-
# @return [Hash]
|
396
|
-
def load_from_content(content)
|
397
|
-
{ cert: load_cert(content) }
|
398
|
-
end
|
399
|
-
|
400
|
-
# Save certificate.
|
401
|
-
# @param [Hash] data
|
402
|
-
# @return [void]
|
403
|
-
def save(data)
|
404
|
-
save_to_file(dump_cert(data[:cert]))
|
405
|
-
end
|
406
|
-
|
407
|
-
end
|
408
|
-
IOPlugin.register(CertFile, 'cert.pem', :pem)
|
409
|
-
IOPlugin.register(CertFile, 'cert.der', :der)
|
410
|
-
|
411
84
|
end
|
85
|
+
|
86
|
+
require_relative 'io_plugins/file_io_plugin_mixin'
|
87
|
+
require_relative 'io_plugins/jwk_io_plugin_mixin'
|
88
|
+
require_relative 'io_plugins/openssl_io_plugin'
|
89
|
+
require_relative 'io_plugins/account_key'
|
90
|
+
require_relative 'io_plugins/key_file'
|
91
|
+
require_relative 'io_plugins/chain_file'
|
92
|
+
require_relative 'io_plugins/full_chain_file'
|
93
|
+
require_relative 'io_plugins/cert_file'
|