letscert 0.4.1 → 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- 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'
|