letscert 0.2.4 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bc09ae0885a6a70be1512b3819c07899cd84ecab
4
- data.tar.gz: 124c5daa4aab3d985d86a1d0fada0c65db0269a2
3
+ metadata.gz: 4d86e5665c2e35534ded942c0603cb47b927e05e
4
+ data.tar.gz: f2d9fa631de0f0aee3ec3e051f6c2a78ff85bdf1
5
5
  SHA512:
6
- metadata.gz: cf9e90399b2277647f72939d7669a0d562add40b1cd3a4a89346d215fe09e7a97bf0eed125342c30bed63096c92945f932276df3d82dc532a83221238cfba0e5
7
- data.tar.gz: ce17392296e10b1d45fff709662ec3de25c53f658b28140a895706b65fe8f0f44959a87cc418d33155246af98afe36639623a503f54a77dc015d00462e1b65ff
6
+ metadata.gz: 103bc74e75d1a69f004951cbd681768a3efd11520c59ed38cc389a81aaf750ec097c94d84a57a9898fc298237309bc84f48d27dc5dd18d371d952ba48eab09e1
7
+ data.tar.gz: 717dfaff074bd6c3809045e56ff51e7518c2c0f2bc2ce5990e8777121b3ea6dda3f67e4b90dec891534dd0f9e4cba60a3f72bd36e2e26e62b18de77ee23b89e3
data/README.md CHANGED
@@ -9,32 +9,47 @@ in Ruby.
9
9
 
10
10
  # Usage
11
11
 
12
- Generate a key pair and get signed certificate:
12
+ ## Generate a key pair and get signed certificate:
13
+ With full chain support (`fullchain.pem` file will contain all certificates):
13
14
  ```bash
14
- letscert -d example.com:/var/www/example.com/html -f account_key.json -f key.pem -f cert.pem -f fullchain.pem
15
+ letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld -f account_key.json -f key.pem -f fullchain.pem
15
16
  ```
16
-
17
- Generate a key pair and get a signed certificate for multi-domains:
17
+ else (certificate for example.com is in `cert.pem` file, rest of certification chain
18
+ is in `chain.pem`):
18
19
  ```bash
19
- letscert -d example.com -d www.example.com --default_root /var/www/html -f account_key.json -f key.pem -f cert.pem -f fullchain.pem
20
+ letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld -f account_key.json -f key.pem -f cert.pem -f chain.pem
20
21
  ```
21
22
 
22
23
  Commands are the sames for certificate renewal.
23
24
 
25
+
26
+ ## Generate a key pair and get a signed certificate for multi-domains:
27
+ Generate a single certificate for `example.com` and `www.example.com`:
28
+ ```bash
29
+ letscert -d example.com -d www.example.com --default-root /var/www/html --email my.name@domain.tld -f account_key.json -f key.pem -f fullchain.pem
30
+ ```
31
+
32
+ Command is the same for certificate renewal.
33
+
34
+ ## Revoke a key pair:
35
+ From directory where are stored `account_key.json` and `cert.pem` or `fullchain.pem`:
36
+ ```bash
37
+ letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld --revoke
38
+ ```
39
+
40
+
24
41
  # What `letscert` do
25
42
 
26
- * Automagically a new ACME account if needed.
43
+ * Automagically create a new ACME account if needed.
27
44
  * Issue new certificate if no previous one found.
28
45
  * Renew certificate only if needed.
29
- * Only `http-01` challenge supported. An existing web server must be alreay running. `letscert` should have write access to `${webroot}/.well-known/acme-challenge`.
46
+ * Only `http-01` challenge supported. An existing web server must be alreay running.
47
+ `letscert` should have write access to `${webroot}/.well-known/acme-challenge`.
30
48
  * Crontab friendly: no promts.
31
49
  * No configuration file.
32
- * Support multiple domains with multiple roots. Always create a single certificate per un
33
- (ie a certificate may have multiple SANs).
50
+ * Support multiple domains with multiple roots. Always create a single certificate per
51
+ run (ie a certificate may have multiple SANs).
34
52
  * As `simp_le`, check the exit code to known if a renewal has happened:
35
53
  * 0 if certificate data was created or updated;
36
54
  * 1 if renewal not necessary;
37
55
  * 2 in case of errors.
38
-
39
- # Todo
40
- Add support to revocation.
data/lib/letscert.rb CHANGED
@@ -24,7 +24,7 @@
24
24
  module LetsCert
25
25
 
26
26
  # Letscert version number
27
- VERSION = '0.2.4'
27
+ VERSION = '0.3.0'
28
28
 
29
29
 
30
30
  # Base error class
@@ -24,34 +24,31 @@ require_relative 'loggable'
24
24
  module LetsCert
25
25
 
26
26
  # Class to handle ACME operations on certificates
27
+ # @author Sylvain Daubert
27
28
  class Certificate
28
29
  include Loggable
29
30
 
30
- # Revoke certificates
31
- # @param [Array<String>] files
32
- def self.revoke(files)
33
- logger.warn "revoke not yet implemented"
31
+
32
+ # @param [OpenSSL::X509::Certificate,nil] cert
33
+ def initialize(cert)
34
+ @cert = cert
34
35
  end
35
36
 
36
37
  # Get a new certificate, or renew an existing one
37
- # @param [Hash] options
38
+ # @param [OpenSSL::PKey::PKey] account_key private key to authenticate to ACME server
39
+ # @param [OpenSSL::PKey::PKey] key private key from which make a certificate
38
40
  # @param [Hash] data
39
- def self.get(options, data)
40
- new.get options, data
41
- end
42
-
43
- def get(options, data)
41
+ def get(account_key, key, options)
44
42
  logger.info {"create key/cert/chain..." }
45
43
  roots = compute_roots(options)
46
44
  logger.debug { "webroots are: #{roots.inspect}" }
47
45
 
48
- client = get_acme_client(data[:account_key], options)
46
+ client = get_acme_client(account_key, options)
49
47
 
50
48
  do_challenges client, roots
51
49
 
52
- if options[:reuse_key] and !data[:key].nil?
50
+ if options[:reuse_key] and !key.nil?
53
51
  logger.info { 'Reuse existing private key' }
54
- key = data[:key]
55
52
  else
56
53
  logger.info { 'Generate new private key' }
57
54
  key = OpenSSL::PKey::RSA.generate(options[:cert_key_size])
@@ -68,6 +65,58 @@ module LetsCert
68
65
  end
69
66
  end
70
67
 
68
+ # Revoke certificate
69
+ # @param [OpenSSL::PKey::PKey] account_key
70
+ # @return [Boolean]
71
+ def revoke(account_key, options)
72
+ if @cert.nil?
73
+ raise Error, 'no certification data to revoke'
74
+ end
75
+
76
+ client = get_acme_client(account_key, options)
77
+ begin
78
+ result = client.revoke_certificate(@cert)
79
+ rescue Exception => ex
80
+ raise
81
+ end
82
+
83
+ if result
84
+ logger.info { 'certificate is revoked' }
85
+ else
86
+ logger.warn { 'certificate is not revoked!' }
87
+ end
88
+
89
+ result
90
+ end
91
+
92
+ # Check if certificate is still valid for at least +valid_min+ seconds.
93
+ # Also checks that +domains+ are certified by certificate.
94
+ # @param [Array<String>] domains list of certificate domains
95
+ # @param [Integer] valid_min
96
+ # @return [Boolean]
97
+ def valid?(domains, valid_min=0)
98
+ if @cert.nil?
99
+ logger.debug { 'no existing certificate' }
100
+ return false
101
+ end
102
+
103
+ subjects = []
104
+ @cert.extensions.each do |ext|
105
+ if ext.oid == 'subjectAltName'
106
+ subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
107
+ end
108
+ end
109
+ logger.debug { "cert SANs: #{subjects.join(', ')}" }
110
+
111
+ # Check all domains are subjects of certificate
112
+ unless domains.all? { |domain| subjects.include? domain }
113
+ raise Error, "At least one domain is not declared as a certificate subject." +
114
+ "Backup and remove existing cert if you want to proceed"
115
+ end
116
+
117
+ !renewal_necessary?(valid_min)
118
+ end
119
+
71
120
 
72
121
  private
73
122
 
@@ -206,6 +255,19 @@ module LetsCert
206
255
  end
207
256
  end
208
257
 
258
+ # Check if a renewal is necessary for +cert+
259
+ # @param [OpenSSL::X509::Certificate] cert
260
+ # @param [Number] valid_min minimum validity in seconds to ensure
261
+ # @return [Boolean]
262
+ def renewal_necessary?(valid_min)
263
+ now = Time.now.utc
264
+ diff = (@cert.not_after - now).to_i
265
+ logger.debug { "Certificate expires in #{diff}s on #{@cert.not_after}" +
266
+ " (relative to #{now})" }
267
+
268
+ diff < valid_min
269
+ end
270
+
209
271
  end
210
272
 
211
273
  end
@@ -130,12 +130,19 @@ module LetsCert
130
130
  Certificate.logger = @logger
131
131
 
132
132
  begin
133
- if @options[:revoke]
134
- Certificate.revoke @options[:files]
135
- RETURN_OK
136
- elsif @options[:domains].empty?
133
+ if @options[:domains].empty?
137
134
  raise Error, "At leat one domain must be given with --domain option.\n" +
138
135
  "Try 'letscert --help' for more information."
136
+ end
137
+
138
+ if @options[:revoke]
139
+ data = load_data_from_disk(IOPlugin.registered.keys)
140
+ certificate = Certificate.new(data[:cert])
141
+ if certificate.revoke(data[:account_key], @options)
142
+ RETURN_OK
143
+ else
144
+ RETURN_ERROR
145
+ end
139
146
  else
140
147
  # Check all components are covered by plugins
141
148
  persisted = IOPlugin.empty_data
@@ -152,12 +159,13 @@ module LetsCert
152
159
 
153
160
  data = load_data_from_disk(@options[:files])
154
161
 
155
- if valid_existing_cert(data[:cert], @options[:domains], @options[:valid_min])
162
+ certificate = Certificate.new(data[:cert])
163
+ if certificate.valid?(@options[:domains], @options[:valid_min])
156
164
  @logger.info { 'no need to update cert' }
157
165
  RETURN_OK
158
166
  else
159
167
  # update/create cert
160
- Certificate.get @options, data
168
+ certificate.get data[:account_key], data[:key], @options
161
169
  RETURN_OK_CERT
162
170
  end
163
171
  end
@@ -197,7 +205,7 @@ module LetsCert
197
205
  @options[:domains] << domain
198
206
  end
199
207
 
200
- opts.on('--default_root PATH', 'Default webroot path',
208
+ opts.on('--default-root PATH', 'Default webroot path',
201
209
  'Use for domains without PATH part.') do |path|
202
210
  @options[:default_root] = path
203
211
  end
@@ -305,48 +313,6 @@ module LetsCert
305
313
  all_data
306
314
  end
307
315
 
308
- # Check if +cert+ exists and is always valid
309
- # @todo For now, only check exitence.
310
- # @param [nil, OpenSSL::X509::Certificate] cert certificate to valid
311
- # @param [Array<String>] domains list if domains to valid
312
- # @param [Number] valid_min minimum validity in seconds to ensure
313
- # @return [Boolean]
314
- def valid_existing_cert(cert, domains, valid_min)
315
- if cert.nil?
316
- @logger.debug { 'no existing cert' }
317
- return false
318
- end
319
-
320
- subjects = []
321
- cert.extensions.each do |ext|
322
- if ext.oid == 'subjectAltName'
323
- subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
324
- end
325
- end
326
- @logger.debug { "cert SANs: #{subjects.join(', ')}" }
327
-
328
- # Check all domains are subjects of certificate
329
- unless domains.all? { |domain| subjects.include? domain }
330
- raise Error, "At least one domain is not declared as a certificate subject." +
331
- "Backup and remove existing cert if you want to proceed"
332
- end
333
-
334
- !renewal_necessary?(cert, valid_min)
335
- end
336
-
337
- # Check if a renewal is necessary for +cert+
338
- # @param [OpenSSL::X509::Certificate] cert
339
- # @param [Number] valid_min minimum validity in seconds to ensure
340
- # @return [Boolean]
341
- def renewal_necessary?(cert, valid_min)
342
- now = Time.now.utc
343
- diff = (cert.not_after - now).to_i
344
- @logger.debug { "Certificate expires in #{diff}s on #{cert.not_after}" +
345
- " (relative to #{now})" }
346
-
347
- diff < valid_min
348
- end
349
-
350
316
  end
351
317
 
352
318
  end
@@ -0,0 +1,56 @@
1
+ require_relative 'spec_helper'
2
+
3
+ module LetsCert
4
+
5
+ describe Certificate do
6
+
7
+ before(:all) { Certificate.logger = Logger.new('/dev/null') }
8
+
9
+ context '#valid?' do
10
+
11
+ before(:all) do
12
+ root_key = OpenSSL::PKey::RSA.new(512)
13
+
14
+ @domains = %w(example.org www.example.org)
15
+
16
+ key = OpenSSL::PKey::RSA.new(512)
17
+ @cert = OpenSSL::X509::Certificate.new
18
+ @cert.version = 2
19
+ @cert.serial = 2
20
+ @cert.issuer = OpenSSL::X509::Name.parse "/DC=letscert/CN=CA"
21
+ @cert.public_key = key.public_key
22
+ @cert.not_before = Time.now
23
+ # 20 days validity
24
+ @cert.not_after = @cert.not_before + 20 * 24 * 60 * 60
25
+ ef = OpenSSL::X509::ExtensionFactory.new
26
+ ef.subject_certificate = @cert
27
+ @domains.each do |domain|
28
+ @cert.add_extension(ef.create_extension('subjectAltName',
29
+ "DNS:#{domain}",
30
+ false))
31
+ end
32
+ @cert.sign(root_key, OpenSSL::Digest::SHA256.new)
33
+ end
34
+
35
+ let(:certificate) { Certificate.new(@cert) }
36
+
37
+ it 'checks whether a certificate is valid given a minimum valid duration' do
38
+ expect(certificate.valid?(@domains)).to be(true)
39
+ expect(certificate.valid?(@domains, 19)).to be(true)
40
+ expect(certificate.valid?(@domains, 21 * 24 * 3600)).to be(false)
41
+ end
42
+
43
+ it 'raises whether a certificate does not validate a given domain' do
44
+ expect(certificate.valid?(@domains)).to be(true)
45
+ expect(certificate.valid?(@domains[0, 1])).to be(true)
46
+
47
+ domains = @domains + %w(another.tld)
48
+ expect { certificate.valid?(domains) }.to raise_error(LetsCert::Error)
49
+ expect { certificate.valid?(%w(another.tld)) }.to raise_error(LetsCert::Error)
50
+ end
51
+
52
+ end
53
+
54
+ end
55
+
56
+ end
@@ -0,0 +1,8 @@
1
+ require_relative 'spec_helper'
2
+
3
+ module LetsCert
4
+
5
+ describe Certificate do
6
+ end
7
+
8
+ end
data/tasks/gem.rake CHANGED
@@ -21,11 +21,13 @@ EOF
21
21
  s.files = files
22
22
  s.executables = ['letscert']
23
23
 
24
+ s.required_ruby_version = '>= 2.1.0'
25
+
24
26
  s.add_dependency 'acme-client', '~>0.3.0'
25
27
  s.add_dependency 'json-jwt', '~>1.5'
26
- s.add_dependency 'yard', '~>0.8'
27
28
 
28
- #s.add_development_dependency 'rspec', '~>3.4'
29
+ s.add_development_dependency 'rspec', '~>3.4'
30
+ s.add_development_dependency 'yard', '~>0.8'
29
31
  end
30
32
 
31
33
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letscert
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sylvain Daubert
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-02-14 00:00:00.000000000 Z
11
+ date: 2016-02-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.5'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.4'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.4'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: yard
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -45,7 +59,7 @@ dependencies:
45
59
  - - "~>"
46
60
  - !ruby/object:Gem::Version
47
61
  version: '0.8'
48
- type: :runtime
62
+ type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
@@ -73,6 +87,8 @@ files:
73
87
  - spec/account_key.json
74
88
  - spec/cert.der
75
89
  - spec/cert.pem
90
+ - spec/certificate_spec.rb
91
+ - spec/certificate_spec.rb~
76
92
  - spec/chain.pem
77
93
  - spec/fullchain.pem
78
94
  - spec/io_plugin_spec.rb
@@ -96,7 +112,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
96
112
  requirements:
97
113
  - - ">="
98
114
  - !ruby/object:Gem::Version
99
- version: '0'
115
+ version: 2.1.0
100
116
  required_rubygems_version: !ruby/object:Gem::Requirement
101
117
  requirements:
102
118
  - - ">="