letscert 0.3.1 → 0.4.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: ecc722b11b425c139e92b66d7436be0b818d0d03
4
- data.tar.gz: 10b112ecdefce5e90e0c5cf8f99cd8bef961c2c0
3
+ metadata.gz: 2aaf92c6fdc9f696efb73eae2d3a86577b2dd37e
4
+ data.tar.gz: ec9885ad548b9ff43a645a2990eff2969326d646
5
5
  SHA512:
6
- metadata.gz: 32d5b6a6cfe0fd937506fc60791c793cac8024ab13a04531fc4a4bc56529074131d27f80c642f2d9e68a6026f3081b3d5578c0c2d1343f8a07e7b5acc1eb484c
7
- data.tar.gz: a2c004cd721d85ce5aec4afe22325eaf9cfd5f70fd173f029bd443826e7cf1361d341ef9db105a69afb91cf6ceca8da37539bdb32e382a629865612101fccbaf
6
+ metadata.gz: 298d19dda74b12aa3b44d67d80ecbf74f26ed9d68609eb8efe6b82caa5fa7f34e3a48460949350b80c0d4abee890d6b13f0fdf32a89445d4cd25cdbe55c79379
7
+ data.tar.gz: f2e907cb8c7da052030e5b743419839f45623a2c612e45f0d2e5e042e5ffd2f32480711d15de6bcce09a1717aae1026b30ea1a96ca431b3da307b35621c24e31
data/README.md CHANGED
@@ -61,7 +61,7 @@ letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld --r
61
61
  * Renew certificate only if needed.
62
62
  * Only `http-01` challenge supported. An existing web server must be alreay running.
63
63
  `letscert` should have write access to `${webroot}/.well-known/acme-challenge`.
64
- * Crontab friendly: no promts.
64
+ * Crontab friendly: no prompts.
65
65
  * No configuration file.
66
66
  * Support multiple domains with multiple roots. Always create a single certificate per
67
67
  run (ie a certificate may have multiple SANs).
@@ -56,6 +56,7 @@ module LetsCert
56
56
  # @option options [Hash] :roots hash associating domains as keys to web roots as
57
57
  # values
58
58
  # @option options [String] :server ACME servel URL
59
+ # @return [void]
59
60
  def get(account_key, key, options)
60
61
  logger.info {"create key/cert/chain..." }
61
62
  check_roots(options[:roots])
@@ -85,8 +86,12 @@ module LetsCert
85
86
 
86
87
  # Revoke certificate
87
88
  # @param [OpenSSL::PKey::PKey] account_key
89
+ # @param [Hash] options
90
+ # @option options [Fixnum] :account_key_size ACME account private key size in bits
91
+ # @option options [String] :email e-mail used as ACME account
92
+ # @option options [String] :server ACME servel URL
88
93
  # @return [Boolean]
89
- def revoke(account_key, options)
94
+ def revoke(account_key, options={})
90
95
  if @cert.nil?
91
96
  raise Error, 'no certification data to revoke'
92
97
  end
@@ -135,27 +140,12 @@ module LetsCert
135
140
  !renewal_necessary?(valid_min)
136
141
  end
137
142
 
138
-
139
- private
140
-
141
- # check webroots.
142
- # @param [Hash] roots
143
- # @raise [Error] if some domains have no defined root.
144
- def check_roots(roots)
145
- no_roots = roots.select { |k,v| v.nil? }
146
-
147
- if !no_roots.empty?
148
- raise Error, 'root for the following domain(s) are not specified: ' +
149
- no_roots.keys.join(', ') + ".\nTry --default_root or use " +
150
- '-d example.com:/var/www/html syntax.'
151
- end
152
- end
153
-
154
143
  # Get ACME client.
155
144
  #
156
145
  # Client is only created on first call, then it is cached.
157
146
  # @param [Hash] account_key
158
147
  # @param [Hash] options
148
+ # @return [Acme::Client]
159
149
  def get_acme_client(account_key, options)
160
150
  return @client if @client
161
151
 
@@ -164,6 +154,8 @@ module LetsCert
164
154
  logger.debug { "connect to #{options[:server]}" }
165
155
  @client = Acme::Client.new(private_key: key, endpoint: options[:server])
166
156
 
157
+ yield @client if block_given?
158
+
167
159
  if options[:email].nil?
168
160
  logger.warn { '--email was not provided. ACME CA will have no way to ' +
169
161
  'contact you!' }
@@ -197,9 +189,26 @@ module LetsCert
197
189
  @client
198
190
  end
199
191
 
192
+
193
+ private
194
+
195
+ # check webroots.
196
+ # @param [Hash] roots
197
+ # @raise [Error] if some domains have no defined root.
198
+ def check_roots(roots)
199
+ no_roots = roots.select { |k,v| v.nil? }
200
+
201
+ if !no_roots.empty?
202
+ raise Error, 'root for the following domain(s) are not specified: ' +
203
+ no_roots.keys.join(', ') + ".\nTry --default_root or use " +
204
+ '-d example.com:/var/www/html syntax.'
205
+ end
206
+ end
207
+
200
208
  # Generate a new account key if no one is given in +data+
201
209
  # @param [OpenSSL::PKey,nil] key
202
- # @param [Hash] options
210
+ # @param [Integer] key_size
211
+ # @return [OpenSSL::PKey::PKey]
203
212
  def get_account_key(key, key_size)
204
213
  if key.nil?
205
214
  logger.info { 'No account key. Generate a new one...' }
@@ -260,8 +269,7 @@ module LetsCert
260
269
  end
261
270
  end
262
271
 
263
- # Check if a renewal is necessary for +cert+
264
- # @param [OpenSSL::X509::Certificate] cert
272
+ # Check if a renewal is necessary
265
273
  # @param [Number] valid_min minimum validity in seconds to ensure
266
274
  # @return [Boolean]
267
275
  def renewal_necessary?(valid_min)
@@ -19,7 +19,7 @@
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 'json/jwt'
22
+ require 'base64'
23
23
  require_relative 'loggable'
24
24
 
25
25
  module LetsCert
@@ -86,7 +86,7 @@ module LetsCert
86
86
  # @author Sylvain Daubert
87
87
  module FileIOPluginMixin
88
88
 
89
- # Load data from file named {#name}
89
+ # Load data from file named +#name+
90
90
  # @return [Hash]
91
91
  def load
92
92
  logger.debug { "Loading #@name" }
@@ -110,7 +110,7 @@ module LetsCert
110
110
  raise NotImplementedError
111
111
  end
112
112
 
113
- # Save data to file {#name}
113
+ # Save data to file +#name+
114
114
  # @param [Hash] data
115
115
  # @return [void]
116
116
  def save_to_file(data)
@@ -134,20 +134,62 @@ module LetsCert
134
134
  # @author Sylvain Daubert
135
135
  module JWKIOPluginMixin
136
136
 
137
+ # Encode string +data+ to base64
138
+ # @param [String] data
139
+ # @return [String]
140
+ def urlsafe_encode64(data)
141
+ Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
142
+ end
143
+
144
+ # Decode base64 string +data+
145
+ # @param [String] data
146
+ # @return [String]
147
+ def urlsafe_decode64(data)
148
+ Base64.urlsafe_decode64(data.sub(/[\s=]*\z/, ''))
149
+ end
150
+
137
151
  # Load crypto data from JSON-encoded file
138
152
  # @param [String] data JSON-encoded data
139
- # @return [Hash]
153
+ # @return [OpenSSL::PKey::PKey]
140
154
  def load_jwk(data)
141
155
  return nil if data.empty?
142
156
 
143
- JSON::JWK.new(JSON.parse(data)).to_key
157
+ h = JSON.parse(data)
158
+ case h['kty']
159
+ when 'RSA'
160
+ pkey = OpenSSL::PKey::RSA.new
161
+ %w(e n d p q).collect do |key|
162
+ next if h[key].nil?
163
+ value = OpenSSL::BN.new(urlsafe_decode64(h[key]), 2)
164
+ pkey.send "#{key}=".to_sym, value
165
+ end
166
+ else
167
+ raise Error, "unknown account key type '#{k['kty']}'"
168
+ end
169
+
170
+ pkey
144
171
  end
145
172
 
146
173
  # Dump crypto data (key) to a JSON-encoded string
147
174
  # @param [OpenSSL::PKey] key
148
175
  # @return [String]
149
176
  def dump_jwk(key)
150
- key.to_jwk.to_json
177
+ return {}.to_json if key.nil?
178
+
179
+ h = { 'kty' => 'RSA' }
180
+ case key
181
+ when OpenSSL::PKey::RSA
182
+ h['e'] = urlsafe_encode64(key.e.to_s(2)) if key.e
183
+ h['n'] = urlsafe_encode64(key.n.to_s(2)) if key.n
184
+ if key.private?
185
+ h['d'] = urlsafe_encode64(key.d.to_s(2))
186
+ h['p'] = urlsafe_encode64(key.p.to_s(2))
187
+ h['q'] = urlsafe_encode64(key.q.to_s(2))
188
+ end
189
+ else
190
+ raise Error, "only RSA keys are supported"
191
+ end
192
+ h.to_json
151
193
  end
152
194
  end
153
195
 
@@ -203,8 +203,10 @@ module LetsCert
203
203
  end
204
204
 
205
205
  rescue Error, Acme::Client::Error => ex
206
- @logger.error ex.message
207
- puts "Error: #{ex.message}"
206
+ msg = ex.message
207
+ msg = "[Acme] #{msg}" if ex.is_a?(Acme::Client::Error)
208
+ @logger.error msg
209
+ puts "Error: #{msg}"
208
210
  RETURN_ERROR
209
211
  end
210
212
  end
data/lib/letscert.rb CHANGED
@@ -24,7 +24,7 @@
24
24
  module LetsCert
25
25
 
26
26
  # Letscert version number
27
- VERSION = '0.3.1'
27
+ VERSION = '0.4.0'
28
28
 
29
29
 
30
30
  # Base error class
@@ -44,9 +44,11 @@ module LetsCert
44
44
  ARGV << '-d' << 'example.com:/var/ww/html'
45
45
  ARGV << '--server' << 'https://acme-staging.api.letsencrypt.org/directory'
46
46
  runner.parse_options
47
- # raise error because no e-mail address was given
48
- expect { certificate.get(nil, nil, runner.options) }.
49
- to raise_error(Acme::Client::Error)
47
+ VCR.use_cassette('single-domain') do
48
+ # raise error because no e-mail address was given
49
+ expect { certificate.get(nil, nil, runner.options) }.
50
+ to raise_error(Acme::Client::Error)
51
+ end
50
52
 
51
53
  ARGV.clear
52
54
  ARGV << '-d' << 'example.com:/var/www/html'
@@ -64,9 +66,11 @@ module LetsCert
64
66
  ARGV << '--default-root' << '/opt/www'
65
67
  ARGV << '--server' << 'https://acme-staging.api.letsencrypt.org/directory'
66
68
  runner.parse_options
67
- # raise error because no e-mail address was given
68
- expect { certificate.get(nil, nil, runner.options) }.
69
- to raise_error(Acme::Client::Error)
69
+ VCR.use_cassette('default-root') do
70
+ # raise error because no e-mail address was given
71
+ expect { certificate.get(nil, nil, runner.options) }.
72
+ to raise_error(Acme::Client::Error)
73
+ end
70
74
  expect(runner.options[:roots]['example.com']).to eq('/var/www/html')
71
75
  expect(runner.options[:roots]['www.example.com']).to eq('/opt/www')
72
76
  end
@@ -74,9 +78,11 @@ module LetsCert
74
78
  it 'uses existing account key' do
75
79
  options = { roots: { 'example.com' => '/var/www/html' } }
76
80
 
77
- # Connection error: no server to connect to
78
- expect { certificate.get(@account_key2048, nil, options) }.
79
- to raise_error(Faraday::ConnectionFailed)
81
+ VCR.use_cassette('no-server') do
82
+ # Connection error: no server to connect to
83
+ expect { certificate.get(@account_key2048, nil, options) }.
84
+ to raise_error(Faraday::ConnectionFailed)
85
+ end
80
86
  expect(certificate.client.private_key).to eq(@account_key2048)
81
87
  end
82
88
 
@@ -86,9 +92,11 @@ module LetsCert
86
92
  account_key_size: 128,
87
93
  }
88
94
 
89
- # Connection error: no server to connect to
90
- expect { certificate.get(nil, nil, options) }.
91
- to raise_error(Faraday::ConnectionFailed)
95
+ VCR.use_cassette('no-server') do
96
+ # Connection error: no server to connect to
97
+ expect { certificate.get(nil, nil, options) }.
98
+ to raise_error(Faraday::ConnectionFailed)
99
+ end
92
100
  expect(certificate.client.private_key).to be_a(OpenSSL::PKey::RSA)
93
101
  end
94
102
 
@@ -98,9 +106,11 @@ module LetsCert
98
106
  server: 'https://acme-staging.api.letsencrypt.org/directory',
99
107
  }
100
108
 
101
- # Acme error: not valid e-mail address
102
- expect { certificate.get(@account_key2048, nil, options) }.
103
- to raise_error(Acme::Client::Error)
109
+ VCR.use_cassette('create-acme-client') do
110
+ # Acme error: not valid e-mail address
111
+ expect { certificate.get(@account_key2048, nil, options) }.
112
+ to raise_error(Acme::Client::Error)
113
+ end
104
114
  expect(certificate.client.private_key).to eq(@account_key2048)
105
115
  expect(certificate.client.instance_eval { @endpoint }).to eq(options[:server])
106
116
  end
@@ -111,12 +121,44 @@ module LetsCert
111
121
  server: 'https://acme-staging.api.letsencrypt.org/directory',
112
122
  }
113
123
 
114
- # Acme error: not valid e-mail address
115
- expect { certificate.get(@account_key2048, nil, options) }.
116
- to raise_error(Acme::Client::Error).
117
- with_message('not a valid e-mail address')
124
+ VCR.use_cassette('create-acme-client-but-bad-email') do
125
+ # Acme error: not valid e-mail address
126
+ expect { certificate.get(@account_key2048, nil, options) }.
127
+ to raise_error(Acme::Client::Error).
128
+ with_message('not a valid e-mail address')
129
+ end
130
+ end
131
+
132
+ it 'responds to HTTP-01 challenge'
133
+
134
+ it 'raises if HTTP-01 challenge is unavailable' do
135
+ options = {
136
+ roots: { 'example.com' => '/var/www/html' },
137
+ server: 'https://acme-staging.api.letsencrypt.org/directory',
138
+ email: 'test@example.org',
139
+ }
140
+
141
+ VCR.use_cassette('no-http-01-challenge') do
142
+ certificate.get_acme_client(@account_key2048, options) do |client|
143
+ client.connection.builder.insert 0, RemoveHttp01Middleware
144
+ end
145
+ expect { certificate.get(@account_key2048, nil, options) }.
146
+ to raise_error(LetsCert::Error).with_message(/not offer http-01/)
147
+ end
148
+ end
149
+
150
+ it 'reuses existing private key if --reuse-key is present'
151
+
152
+ end
153
+
154
+ context '#revoke' do
155
+ it 'raises if no certificate is given' do
156
+ certificate = Certificate.new(nil)
157
+ expect { certificate.revoke(@account_key2048) }.
158
+ to raise_error(LetsCert::Error)
118
159
  end
119
160
 
161
+ it 'revokes an existing certificate'
120
162
  end
121
163
 
122
164
  context '#valid?' do
data/spec/spec_helper.rb CHANGED
@@ -1,2 +1,28 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__), '..', 'lib')
2
2
  require 'letscert'
3
+
4
+ require 'vcr'
5
+ require 'faraday'
6
+
7
+ VCR.configure do |config|
8
+ config.cassette_library_dir = "fixtures/vcr_cassettes"
9
+ config.hook_into :faraday
10
+ end
11
+
12
+
13
+ # Faraday Middleware to remove HTTP-01 challenge
14
+ class RemoveHttp01Middleware < Faraday::Middleware
15
+ def call(request_env)
16
+ @app.call(request_env).on_complete do |response_env|
17
+ body = response_env.response.body
18
+ if body['challenges'] and !body['challenges'].empty?
19
+ body['challenges'].each_with_index do |challenge, index|
20
+ if challenge['type'] == 'http-01'
21
+ body['challenges'].delete_at(index)
22
+ break
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
data/tasks/gem.rake CHANGED
@@ -23,10 +23,12 @@ EOF
23
23
 
24
24
  s.required_ruby_version = '>= 2.1.0'
25
25
 
26
- s.add_dependency 'acme-client', '~>0.3.0'
27
- s.add_dependency 'json-jwt', '~>1.5'
26
+ s.add_dependency 'acme-client', '~>0.4.0'
27
+ s.add_dependency 'json', '~>1.8.3'
28
28
 
29
29
  s.add_development_dependency 'rspec', '~>3.4'
30
+ s.add_development_dependency 'vcr', '~>3.0'
31
+ s.add_development_dependency 'faraday', '~>0.9'
30
32
  s.add_development_dependency 'yard', '~>0.8'
31
33
  end
32
34
 
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.3.1
4
+ version: 0.4.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-22 00:00:00.000000000 Z
11
+ date: 2016-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.3.0
19
+ version: 0.4.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.3.0
26
+ version: 0.4.0
27
27
  - !ruby/object:Gem::Dependency
28
- name: json-jwt
28
+ name: json
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.5'
33
+ version: 1.8.3
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.5'
40
+ version: 1.8.3
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.4'
55
+ - !ruby/object:Gem::Dependency
56
+ name: vcr
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: faraday
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: yard
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -88,7 +116,6 @@ files:
88
116
  - spec/cert.der
89
117
  - spec/cert.pem
90
118
  - spec/certificate_spec.rb
91
- - spec/certificate_spec.rb~
92
119
  - spec/chain.pem
93
120
  - spec/fullchain.pem
94
121
  - spec/io_plugin_spec.rb
@@ -96,7 +123,6 @@ files:
96
123
  - spec/key.pem
97
124
  - spec/loggable_spec.rb
98
125
  - spec/runner_spec.rb
99
- - spec/runner_spec.rb~
100
126
  - spec/spec_helper.rb
101
127
  - spec/test.json
102
128
  - tasks/gem.rake
@@ -122,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
148
  version: '0'
123
149
  requirements: []
124
150
  rubyforge_project:
125
- rubygems_version: 2.4.5.1
151
+ rubygems_version: 2.5.1
126
152
  signing_key:
127
153
  specification_version: 4
128
154
  summary: letscert, a simple Let's Encrypt client
@@ -1,8 +0,0 @@
1
- require_relative 'spec_helper'
2
-
3
- module LetsCert
4
-
5
- describe Certificate do
6
- end
7
-
8
- end
data/spec/runner_spec.rb~ DELETED
@@ -1,8 +0,0 @@
1
- require_relative 'spec_helper'
2
-
3
- module LetsCert
4
-
5
- describe Runner do
6
- end
7
-
8
- end