acme-pki 0.1.3 → 0.2.3

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
- SHA1:
3
- metadata.gz: 9307776044f052681b8e87045cf8e346ec044fa7
4
- data.tar.gz: c396901a57606839834a4d543d4b94f217a280c1
2
+ SHA256:
3
+ metadata.gz: 7599cb3c3ceecf3e1ce1fb057339106937398c2ce6887a9f16442de981d351af
4
+ data.tar.gz: c81b97fc9132d5442185cfc4ea011b93175e8568dfeb582c0f1e7834ba3b2942
5
5
  SHA512:
6
- metadata.gz: f0a079de8d32ae3d11b8e4d5d4a9aef6f949a01680ce9d51c24b8d580b88f96106dfe1639fd73bf8532a16870dc0b23679c7c12606564ec6d8e6aa0b9e6a73d9
7
- data.tar.gz: 68f54cca1e87abf914f30f62fb8016bb208f2fb4176f37c83942981a26e47a6289e9995262fc7e879f675e1e83934500a0b17eb3dd9b83e17f6f588bbd7242a5
6
+ metadata.gz: ade350294c8dc14ee322120e342697d5f98b0d66cc98b611e908fda4502e011b196ca25ba0b0b548579800db9865600898cc77f3cf2a035c1ed17e36f5acfcd5
7
+ data.tar.gz: beeb43ea7b6d2e2f2551bc114edcc69349f7bcd85d66f54a872dce086bc062bbd56b77ae619b5344404a786c7b7e05a5ed13b9efd585bddff4f6c897321b8aa9
data/.gitignore CHANGED
@@ -1,4 +1,3 @@
1
1
  *.gem
2
2
  *.iml
3
- Gemfile.lock
4
- /pki/
3
+ pki/
@@ -0,0 +1,47 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ acme-pki (0.2.3)
5
+ acme-client (~> 2.0)
6
+ colorize (~> 0.8)
7
+ faraday_middleware (~> 0.13)
8
+ simpleidn (~> 0.1)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ acme-client (2.0.6)
14
+ faraday (>= 0.17, < 2.0.0)
15
+ awesome_print (1.8.0)
16
+ byebug (11.1.3)
17
+ coderay (1.1.2)
18
+ colorize (0.8.1)
19
+ faraday (0.17.3)
20
+ multipart-post (>= 1.2, < 3)
21
+ faraday_middleware (0.14.0)
22
+ faraday (>= 0.7.4, < 1.0)
23
+ method_source (1.0.0)
24
+ multipart-post (2.1.1)
25
+ pry (0.13.1)
26
+ coderay (~> 1.1)
27
+ method_source (~> 1.0)
28
+ pry-byebug (3.9.0)
29
+ byebug (~> 11.0)
30
+ pry (~> 0.13.0)
31
+ simpleidn (0.1.1)
32
+ unf (~> 0.1.4)
33
+ unf (0.1.4)
34
+ unf_ext
35
+ unf_ext (0.0.7.7)
36
+
37
+ PLATFORMS
38
+ ruby
39
+
40
+ DEPENDENCIES
41
+ acme-pki!
42
+ awesome_print (~> 1.8)
43
+ bundler (~> 2.0)
44
+ pry-byebug (~> 3.7)
45
+
46
+ BUNDLED WITH
47
+ 2.1.4
@@ -1,13 +1,14 @@
1
- # coding: utf-8
2
1
  lib = File.expand_path('../lib', __FILE__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
3
 
4
+ require 'acme/pki/version'
5
+
5
6
  Gem::Specification.new do |spec|
6
7
  spec.name = 'acme-pki'
7
- spec.version = '0.1.3'
8
+ spec.version = Acme::PKI::VERSION
8
9
  spec.authors = ['Aeris']
9
10
  spec.email = ['aeris@imirhil.fr']
10
- spec.summary = %q{Ruby client for Lets Encrypt}
11
+ spec.summary = %q{Ruby client for Let's Encrypt}
11
12
  spec.description = %q{Manage your keys, requests and certificates.}
12
13
  spec.homepage = 'https://github.com/aeris/acme-pki/'
13
14
  spec.license = 'AGPL-3.0+'
@@ -17,10 +18,12 @@ Gem::Specification.new do |spec|
17
18
  spec.test_files = spec.files.grep %r{^(test|spec|features)/}
18
19
  spec.require_paths = %w(lib)
19
20
 
20
- spec.add_development_dependency 'bundler', '~> 1.11'
21
+ spec.add_development_dependency 'bundler', '~> 2.0'
22
+ spec.add_development_dependency 'awesome_print', '~> 1.8'
23
+ spec.add_development_dependency 'pry-byebug', '~> 3.7'
21
24
 
22
- spec.add_dependency 'acme-client', '~> 0.3.1'
23
- spec.add_dependency 'faraday_middleware', '~> 0.10.0'
24
- spec.add_dependency 'colorize', '~> 0.7.7'
25
- spec.add_dependency 'simpleidn', '~> 0.0.7'
25
+ spec.add_dependency 'acme-client', '~> 2.0'
26
+ spec.add_dependency 'faraday_middleware', '~> 0.13'
27
+ spec.add_dependency 'colorize', '~> 0.8'
28
+ spec.add_dependency 'simpleidn', '~> 0.1'
26
29
  end
@@ -3,77 +3,101 @@ require 'acme/pki'
3
3
 
4
4
  pki = Acme::PKI.new
5
5
 
6
+ MYNAME = File.basename $PROGRAM_NAME
7
+
8
+ HELP = <<-"EOTEXT"
9
+ #{MYNAME} v#{Acme::PKI::VERSION}
10
+
11
+ Available Commands:
12
+ crt
13
+ csr
14
+ help
15
+ info
16
+ key
17
+ register
18
+ renew
19
+ EOTEXT
20
+
21
+ # if nothing, force help
22
+ ARGV << 'help' if ARGV.length.zero?
23
+
6
24
  case ARGV.shift
7
- when 'register'
8
- OptionParser.new do |opts|
9
- opts.banner = "Usage: #{File.basename __FILE__} register <email>"
10
- end.parse!
11
- if ARGV.empty?
12
- puts "An email address is required !"
13
- exit -1
14
- end
15
- pki.register ARGV.first
16
- when 'key'
17
- options = OpenStruct.new type: Acme::PKI::DEFAULT_KEY
18
- OptionParser.new do |opts|
19
- opts.banner = "Usage: #{File.filename __FILE__} key <domain> [options]"
20
- opts.on('-r [KEYSIZE]', '--rsa [KEYSIZE]', 'RSA key, key size') { |k| options.type = [:rsa, k.to_i] }
21
- opts.on('-e [CURVE]', '--ecc [CURVE]', 'ECC key, curve') { |k| options.type = [:ecc, k] }
22
- end.parse!
23
- if ARGV.empty?
24
- puts "A domain is required !"
25
- exit -1
26
- end
27
- pki.generate_key ARGV.first, type: options.type
28
- when 'csr'
29
- options = OpenStruct.new domains: []
30
- OptionParser.new do |opts|
31
- opts.banner = "Usage: #{File.basename __FILE__} csr <domain> [options]"
32
- opts.on('-k [KEYFILE]', '--key [KEYFILE]', 'Key file') { |k| options.key = k }
33
- opts.on('-d [DOMAIN]', '--domain [DOMAIN]', 'Domain') { |d| options.domains << d }
34
- end.parse!
35
- if ARGV.empty?
36
- puts "A domain is required !"
37
- exit -1
38
- end
39
- pki.generate_csr ARGV.first, key: options.key, domains: options.domains
40
- when 'crt'
41
- options = OpenStruct.new
42
- OptionParser.new do |opts|
43
- opts.banner = "Usage: #{File.basename __FILE__} crt <domain> [options]"
44
- opts.on('-c [CSR]', '--csr [CSR]', 'CSR file') { |c| options.csr = c }
45
- end.parse!
46
- if ARGV.empty?
47
- puts "A domain is required !"
48
- exit -1
49
- end
50
- pki.generate_crt ARGV.first, csr: options.csr
51
- when 'renew'
52
- options = OpenStruct.new
53
- OptionParser.new do |opts|
54
- opts.banner = "Usage: #{File.basename __FILE__} renew <domain> [options]"
55
- opts.on('-c [CSR]', '--csr [CSR]', 'CSR file') { |c| options.csr = c }
56
- end.parse!
57
- if ARGV.empty?
58
- puts "A domain is required !"
59
- exit -1
60
- end
61
- exit pki.renew(ARGV.first, csr: options.csr) ? 0 : 1
62
- when 'info'
63
- type = :key
64
- OptionParser.new do |opts|
65
- opts.banner = "Usage: #{File.basename __FILE__} info <domain> [options]"
66
- opts.on('-k', '--key', 'Key information') { type = :key }
67
- opts.on('-c', '--crt', 'Certificate information') { type = :crt }
68
- end.parse!
69
- if ARGV.empty?
70
- puts "A domain is required !"
71
- exit -1
72
- end
73
- case type
74
- when :key
75
- pki.key_info pki.key ARGV.first
76
- when :crt
77
- pki.chain_info pki.crt ARGV.first
78
- end
25
+ when /help|-[hH]|--help/
26
+ puts HELP
27
+ exit 0
28
+ when 'register'
29
+ OptionParser.new do |opts|
30
+ opts.banner = "Usage: #{File.basename __FILE__} register <email>"
31
+ end.parse!
32
+ if ARGV.empty?
33
+ puts "An email address is required !"
34
+ exit -1
35
+ end
36
+ pki.register ARGV.first
37
+ when 'key'
38
+ options = OpenStruct.new type: Acme::PKI::DEFAULT_KEY_TYPE
39
+ OptionParser.new do |opts|
40
+ opts.banner = "Usage: #{File.basename __FILE__} key <domain> [options]"
41
+ opts.on('-r [KEYSIZE]', '--rsa [KEYSIZE]', 'RSA key, key size') { |k| options.type = [:rsa, k.to_i] }
42
+ opts.on('-e [CURVE]', '--ecc [CURVE]', 'ECC key, curve') { |k| options.type = [:ecc, k] }
43
+ end.parse!
44
+ if ARGV.empty?
45
+ puts 'A domain is required !'
46
+ exit -1
47
+ end
48
+ pki.generate_key ARGV.first, type: options.type
49
+ when 'csr'
50
+ options = OpenStruct.new domains: [], adds: [], removes: []
51
+ OptionParser.new do |opts|
52
+ opts.banner = "Usage: #{File.basename __FILE__} csr <domain> [options]"
53
+ opts.on('-k [KEYFILE]', '--key [KEYFILE]', 'Key file') { |k| options.key = k }
54
+ opts.on('-d [DOMAIN]', '--domain [DOMAIN]', 'Domain') { |d| options.domains << d }
55
+ opts.on('-a [DOMAIN]', '--add [DOMAIN]', 'Add domain') { |d| options.adds << d }
56
+ opts.on('-r [DOMAIN]', '--remove [DOMAIN]', 'Remove domain') { |d| options.removes << d }
57
+ end.parse!
58
+ if ARGV.empty?
59
+ puts 'A domain is required !'
60
+ exit -1
61
+ end
62
+ pki.generate_csr ARGV.first, key: options.key, domains: options.domains,
63
+ add: options.adds, remove: options.removes
64
+ when 'crt'
65
+ options = OpenStruct.new
66
+ OptionParser.new do |opts|
67
+ opts.banner = "Usage: #{File.basename __FILE__} crt <domain> [options]"
68
+ opts.on('-c [CSR]', '--csr [CSR]', 'CSR file') { |c| options.csr = c }
69
+ end.parse!
70
+ if ARGV.empty?
71
+ puts 'A domain is required !'
72
+ exit -1
73
+ end
74
+ pki.generate_crt ARGV.first, csr: options.csr
75
+ when 'renew'
76
+ options = OpenStruct.new
77
+ OptionParser.new do |opts|
78
+ opts.banner = "Usage: #{File.basename __FILE__} renew <domain> [options]"
79
+ opts.on('-c [CSR]', '--csr [CSR]', 'CSR file') { |c| options.csr = c }
80
+ end.parse!
81
+ if ARGV.empty?
82
+ puts 'A domain is required !'
83
+ exit -1
84
+ end
85
+ exit pki.renew(ARGV.first, csr: options.csr) ? 0 : 1
86
+ when 'info'
87
+ type = :key
88
+ OptionParser.new do |opts|
89
+ opts.banner = "Usage: #{File.basename __FILE__} info <domain> [options]"
90
+ opts.on('-k', '--key', 'Key information') { type = :key }
91
+ opts.on('-c', '--crt', 'Certificate information') { type = :crt }
92
+ end.parse!
93
+ if ARGV.empty?
94
+ puts 'A domain is required !'
95
+ exit -1
96
+ end
97
+ case type
98
+ when :key
99
+ pki.key_info pki.key ARGV.first
100
+ when :crt
101
+ pki.chain_info pki.crt ARGV.first
102
+ end
79
103
  end
@@ -10,267 +10,294 @@ require 'simpleidn'
10
10
 
11
11
  require 'acme/pki/monkey_patch'
12
12
  require 'acme/pki/information'
13
+ require 'acme/pki/version'
13
14
 
14
15
  module Acme
15
- class PKI
16
- include Information
17
-
18
- DEFAULT_ENDPOINT = ENV['ACME_ENDPOINT'] || 'https://acme-v01.api.letsencrypt.org/'
19
- DEFAULT_ACCOUNT_KEY = ENV['ACME_ACCOUNT_KEY'] || 'account.key'
20
- DEFAULT_KEY = [:ecc, 'secp384r1']
21
- #DEFAULT_KEY = [:rsa, 4096]
22
- DEFAULT_RENEW_DURATION = 60*60*24*30 # 1 month
23
-
24
- def initialize(directory: Dir.pwd, account_key: DEFAULT_ACCOUNT_KEY, endpoint: DEFAULT_ENDPOINT)
25
- @directory = directory
26
- @challenge_dir = ENV['ACME_CHALLENGE'] || File.join(@directory, 'acme-challenge')
27
- @account_key_file = File.join @directory, account_key
28
- @account_key = if File.exists? @account_key_file
29
- open(@account_key_file, 'r') { |f| OpenSSL::PKey.read f }
30
- else
31
- nil
32
- end
33
- @endpoint = endpoint
34
- end
35
-
36
- def key(name)
37
- file name, 'pem'
38
- end
39
-
40
- def csr(name)
41
- file name, 'csr'
42
- end
43
-
44
- def crt(name)
45
- file name, 'crt'
46
- end
47
-
48
- def register(mail)
49
- process("Generating RSA 4096 bits account key into #{@account_key_file}") do
50
- @account_key = OpenSSL::PKey::RSA.new 4096
51
- File.write @account_key_file, @account_key.to_pem
52
- end
53
-
54
- process("Registering account key #{@account_key_file}") do
55
- registration = client.register contact: "mailto:#{mail}"
56
- registration.agree_terms
57
- end
58
- end
59
-
60
- def generate_key(name, type: DEFAULT_KEY)
61
- key_file = self.key name
62
- type, size = type
63
-
64
- key = case type
65
- when :rsa
66
- process "Generating RSA #{size} bits private key into #{key_file}" do
67
- key = OpenSSL::PKey::RSA.new size
68
- open(key_file, 'w') { |f| f.write key.to_pem }
69
- key
70
- end
71
- when :ecc
72
- process "Generating ECC #{size} curve private key into #{key_file}" do
73
- key = OpenSSL::PKey::EC.new(size).generate_key
74
- open(key_file, 'w') { |f| f.write key.to_pem }
75
- key
76
- end
77
- end
78
-
79
- key_info key
80
- end
81
-
82
- def generate_csr(csr, domains: [], key: nil)
83
- key = csr unless key
84
- domains = [csr, *domains].collect { |d| SimpleIDN.to_ascii d }
85
- csr_file = self.csr csr
86
- key_file = self.key key
87
-
88
- generate_key key unless File.exist? key_file
89
-
90
- process "Generating CSR for #{domains.join ', '} with key #{key_file} into #{csr_file}" do
91
- key_file = open(key_file, 'r') { |f| OpenSSL::PKey.read f }
92
- csr = OpenSSL::X509::Request.new
93
- csr.subject = OpenSSL::X509::Name.parse "/CN=#{domains.first}"
94
-
95
- public_key = case key_file
96
- when OpenSSL::PKey::EC
97
- curve = key_file.group.curve_name
98
- public = OpenSSL::PKey::EC.new curve
99
- public.public_key = key_file.public_key
100
- public
101
- else
102
- key_file.public_key
103
- end
104
- csr.public_key = public_key
105
-
106
- factory = OpenSSL::X509::ExtensionFactory.new
107
- extensions = []
108
- #extensions << factory.create_extension('basicConstraints', 'CA:FALSE', true)
109
- extensions << factory.create_extension('keyUsage', 'digitalSignature,nonRepudiation,keyEncipherment')
110
- extensions << factory.create_extension('subjectAltName', domains.collect { |d| "DNS:#{d}" }.join(', '))
111
-
112
- extensions = OpenSSL::ASN1::Sequence extensions
113
- extensions = OpenSSL::ASN1::Set [extensions]
114
- csr.add_attribute OpenSSL::X509::Attribute.new 'extReq', extensions
115
-
116
- csr.sign key_file, OpenSSL::Digest::SHA512.new
117
- open(csr_file, 'w') { |f| f.write csr.to_pem }
118
- end
119
- end
120
-
121
- def generate_crt(crt, csr: nil)
122
- csr = crt unless csr
123
- short_csr = csr
124
- crt = self.crt crt
125
- csr = self.csr csr
126
- generate_csr short_csr unless File.exist? csr
127
- internal_generate_crt crt, csr: csr
128
- end
129
-
130
- def renew(crt, csr: nil, duration: DEFAULT_RENEW_DURATION)
131
- csr = crt unless csr
132
- crt = self.crt crt
133
- csr = self.csr csr
134
- puts "Renewing #{crt} CRT from #{csr} CSR"
135
-
136
- if File.exists? crt
137
- x509 = OpenSSL::X509::Certificate.new File.read crt
138
- delay = x509.not_after - Time.now
139
- if delay > duration
140
- puts "No need to renew (#{humanize delay})"
141
- return false
142
- end
143
- end
144
-
145
- internal_generate_crt crt, csr: csr
146
- true
147
- end
148
-
149
- private
150
- def client
151
- unless @account_key
152
- puts 'No account key available'.colorize :yellow
153
- puts 'Please register yourself before'.colorize :red
154
- exit -1
155
- end
156
- @client ||= Acme::Client.new private_key: @account_key, endpoint: @endpoint
157
- end
158
-
159
- def process(line, io: STDOUT)
160
- io.print "#{line}..."
161
- io.flush
162
-
163
- result = yield
164
-
165
- io.puts " [#{'OK'.colorize :green}]"
166
-
167
- result
168
- rescue Exception
169
- io.puts " [#{'KO'.colorize :red}]"
170
- raise
171
- end
172
-
173
- def file(name, extension=nil)
174
- return nil unless name
175
- name = name.split('.').reverse.join('.')
176
- name = "#{name}.#{extension}" if extension
177
- File.join @directory, name
178
- end
179
-
180
- def domains(csr)
181
- domains = []
182
-
183
- cn = csr.subject.to_a.first { |n, _, _| n == 'CN' }
184
- domains << cn[1] if cn
185
-
186
- attribute = csr.attributes.detect { |a| %w(extReq msExtReq).include? a.oid }
187
- if attribute
188
- set = OpenSSL::ASN1.decode attribute.value
189
- seq = set.value.first
190
- sans = seq.value.collect { |s| OpenSSL::X509::Extension.new(s).to_a }
191
- .detect { |e| e.first == 'subjectAltName' }
192
- if sans
193
- sans = sans[1]
194
- sans = sans.split(/\s*,\s*/)
195
- .collect { |s| s.split /\s*:\s*/ }
196
- .select { |t, _| t == 'DNS' }
197
- .collect { |_, v| v }
198
- domains.concat sans
199
- end
200
- end
201
-
202
- domains.uniq
203
- end
204
-
205
- def authorize(domain)
206
- authorization = client.authorize domain: domain
207
- challenge = authorization.http01
208
-
209
- unless Dir.exists? @challenge_dir
210
- process "Creating directory #{@challenge_dir}" do
211
- FileUtils.mkdir_p @challenge_dir
212
- end
213
- end
214
-
215
- filename = challenge.token
216
- file = File.join @challenge_dir, filename
217
- content = challenge.file_content
218
- process "Writing challenge for #{domain} into #{file}" do
219
- File.write file, content
220
- end
221
-
222
- url = "http://#{domain}/.well-known/acme-challenge/#{filename}"
223
- process "Test challenge for #{url}" do
224
- response = begin
225
- Faraday.new do |conn|
226
- conn.use FaradayMiddleware::FollowRedirects
227
- conn.adapter Faraday.default_adapter
228
- end.get url
229
- rescue => e
230
- raise Exception, e.message
231
- end
232
- raise Exception, "Got response code #{response.status}" unless response.success?
233
- real_content = response.body
234
- raise Exception, "Got #{real_content}, expected #{content}" unless real_content == content
235
- end
236
-
237
- process "Authorizing domain #{domain}" do
238
- challenge.request_verification
239
- status = nil
240
- 60.times do
241
- sleep 1
242
- status = challenge.verify_status
243
- break if status != 'pending'
244
- end
245
-
246
- raise Exception, "Got status #{status} instead of valid" unless status == 'valid'
247
- end
248
- end
249
-
250
- def internal_generate_crt(crt, csr: nil)
251
- csr = crt unless csr
252
- csr_file = csr
253
- csr = OpenSSL::X509::Request.new File.read csr
254
- domains = domains csr
255
-
256
- domains.each { |d| authorize d }
257
-
258
- crt = process "Generating CRT #{crt} from CSR #{csr_file}" do
259
- certificate = client.new_certificate csr
260
- File.write crt, certificate.fullchain_to_pem
261
- OpenSSL::X509::Certificate.new certificate.to_pem
262
- end
263
-
264
- certifificate_info crt
265
- end
266
-
267
- def humanize(secs)
268
- [[60, :seconds], [60, :minutes], [24, :hours], [30, :days], [12, :months]].map { |count, name|
269
- if secs > 0
270
- secs, n = secs.divmod count
271
- "#{n.to_i} #{name}"
272
- end
273
- }.compact.reverse.join(' ')
274
- end
275
- end
16
+ class PKI
17
+ include Information
18
+
19
+ if ENV['ACME_STAGING']
20
+ puts 'Using Let\'s Encrypt ACME staging'.colorize :yellow
21
+ ENV['ACME_ENDPOINT'] = 'https://acme-staging-v02.api.letsencrypt.org/directory'
22
+ ENV['ACME_ACCOUNT_KEY'] = 'account-staging'
23
+ end
24
+ DEFAULT_ENDPOINT = ENV['ACME_ENDPOINT'] || 'https://acme-v02.api.letsencrypt.org/directory'
25
+ DEFAULT_DIRECTORY = ENV['ACME_DIRECTORY'] || Dir.pwd
26
+ DEFAULT_ACCOUNT_KEY = ENV['ACME_ACCOUNT_KEY'] || 'account'
27
+ DEFAULT_ACCOUNT_KEY_TYPE = [:rsa, 4096].freeze
28
+ DEFAULT_KEY_TYPE = [:ecc, 'prime256v1'].freeze
29
+ DEFAULT_RENEW_DURATION = 60 * 60 * 24 * 30 # 1 month
30
+
31
+ def initialize(directory: DEFAULT_DIRECTORY, account_key: DEFAULT_ACCOUNT_KEY, endpoint: DEFAULT_ENDPOINT)
32
+ @directory = directory
33
+ @challenge_dir = ENV['ACME_CHALLENGE'] || File.join(@directory, 'acme-challenge')
34
+ @account_key_file = self.key DEFAULT_ACCOUNT_KEY, 'key'
35
+ @account_key = if File.exists? @account_key_file
36
+ open(@account_key_file, 'r') { |f| OpenSSL::PKey.read f }
37
+ else
38
+ nil
39
+ end
40
+ @endpoint = endpoint
41
+ end
42
+
43
+ def key(name, extension = 'pem')
44
+ self.file name, extension
45
+ end
46
+
47
+ def csr(name)
48
+ self.file name, 'csr'
49
+ end
50
+
51
+ def crt(name)
52
+ self.file name, 'crt'
53
+ end
54
+
55
+ def register(mail)
56
+ @account_key_file, @account_key = self.generate_key DEFAULT_ACCOUNT_KEY, 'key', type: DEFAULT_ACCOUNT_KEY_TYPE
57
+ tos = self.client.meta['termsOfService']
58
+ $stdout.print "Are you agree with Let's Encrypt terms of service available at #{tos}? [yN] "
59
+ $stdout.flush
60
+ accept = $stdin.gets.chomp.downcase == 'y'
61
+ exit unless accept
62
+ self.process("Registering account key #{@account_key_file}") do
63
+ self.client.new_account contact: "mailto:#{mail}", terms_of_service_agreed: accept
64
+ end
65
+ end
66
+
67
+ def generate_key(name, extension = 'pem', type: DEFAULT_KEY_TYPE)
68
+ key_file = self.key name, extension
69
+ type, size = type
70
+
71
+ log = case type
72
+ when :rsa
73
+ "RSA #{size} bits"
74
+ when :ecc
75
+ "ECC #{size} curve"
76
+ end
77
+
78
+ key = self.process "Generating #{log} private key into #{key_file}" do
79
+ key = case type
80
+ when :rsa
81
+ OpenSSL::PKey::RSA.new size
82
+ when :ecc
83
+ OpenSSL::PKey::EC.new(size).generate_key
84
+ end
85
+ open(key_file, 'w') { |f| f.write key.to_pem }
86
+ key
87
+ end
88
+ self.key_info key
89
+ [key_file, key]
90
+ end
91
+
92
+ def generate_csr(csr, domains: [], add: [], remove: [], key: nil)
93
+ key = csr unless key
94
+ csr_file = self.csr csr
95
+ key_file = self.key key
96
+ self.generate_key key unless File.exist? key_file
97
+
98
+ domains = if add.empty? && remove.empty?
99
+ [csr, *domains]
100
+ else
101
+ tmp = OpenSSL::X509::Request.new File.read csr_file
102
+ domains = self.domains tmp
103
+ domains - remove + add
104
+ end.collect { |d| SimpleIDN.to_ascii d }
105
+
106
+ self.process "Generating CSR for #{domains.join ', '} with key #{key_file} into #{csr_file}" do
107
+ key_file = open(key_file, 'r') { |f| OpenSSL::PKey.read f }
108
+ csr = OpenSSL::X509::Request.new
109
+ csr.subject = OpenSSL::X509::Name.parse "/CN=#{domains.first}"
110
+
111
+ public_key = case key_file
112
+ when OpenSSL::PKey::EC
113
+ curve = key_file.group.curve_name
114
+ public = OpenSSL::PKey::EC.new curve
115
+ public.public_key = key_file.public_key
116
+
117
+ public
118
+ else
119
+ key_file.public_key
120
+ end
121
+ csr.public_key = public_key
122
+
123
+ factory = OpenSSL::X509::ExtensionFactory.new
124
+ extensions = []
125
+ #extensions << factory.create_extension('basicConstraints', 'CA:FALSE', true)
126
+ extensions << factory.create_extension('keyUsage', 'digitalSignature,nonRepudiation,keyEncipherment')
127
+ extensions << factory.create_extension('subjectAltName', domains.collect { |d| "DNS:#{d}" }.join(', '))
128
+
129
+ extensions = OpenSSL::ASN1::Sequence extensions
130
+ extensions = OpenSSL::ASN1::Set [extensions]
131
+ csr.add_attribute OpenSSL::X509::Attribute.new 'extReq', extensions
132
+
133
+ csr.sign key_file, OpenSSL::Digest::SHA512.new
134
+ open(csr_file, 'w') { |f| f.write csr.to_pem }
135
+ end
136
+ end
137
+
138
+ def generate_crt(crt, csr: nil)
139
+ csr = crt unless csr
140
+ short_csr = csr
141
+ crt = self.crt crt
142
+ csr = self.csr csr
143
+ self.generate_csr short_csr unless File.exist? csr
144
+ self.internal_generate_crt crt, csr: csr
145
+ end
146
+
147
+ def renew(crt, csr: nil, duration: DEFAULT_RENEW_DURATION)
148
+ csr = crt unless csr
149
+ crt = self.crt crt
150
+ csr = self.csr csr
151
+ puts "Renewing #{crt} CRT from #{csr} CSR"
152
+
153
+ if File.exists? crt
154
+ x509 = OpenSSL::X509::Certificate.new File.read crt
155
+ delay = x509.not_after - Time.now
156
+ if delay > duration
157
+ puts "No need to renew (#{humanize delay})"
158
+ return false
159
+ end
160
+ end
161
+
162
+ self.internal_generate_crt crt, csr: csr
163
+ true
164
+ end
165
+
166
+ def client
167
+ unless @account_key
168
+ puts 'No account key available'.colorize :yellow
169
+ puts 'Please register yourself before'.colorize :red
170
+ exit -1
171
+ end
172
+ @client ||= Acme::Client.new private_key: @account_key, directory: @endpoint
173
+ end
174
+
175
+ def process(line, io: STDOUT)
176
+ io.print "#{line}..."
177
+ io.flush
178
+
179
+ result = yield
180
+
181
+ io.puts " [#{'OK'.colorize :green}]"
182
+
183
+ result
184
+ rescue Exception
185
+ io.puts " [#{'KO'.colorize :red}]"
186
+ raise
187
+ end
188
+
189
+ def file(name, extension = nil)
190
+ return nil unless name
191
+ name = name.split('.').reverse.join('.')
192
+ name = "#{name}.#{extension}" if extension
193
+ File.join @directory, name
194
+ end
195
+
196
+ def domains(csr)
197
+ domains = []
198
+
199
+ cn = csr.subject.to_a.first { |n, _, _| n == 'CN' }
200
+ domains << cn[1] if cn
201
+
202
+ attribute = csr.attributes.detect { |a| %w(extReq msExtReq).include? a.oid }
203
+ if attribute
204
+ set = OpenSSL::ASN1.decode attribute.value
205
+ seq = set.value.first
206
+ sans = seq.value.collect { |s| OpenSSL::X509::Extension.new(s).to_a }
207
+ .detect { |e| e.first == 'subjectAltName' }
208
+ if sans
209
+ sans = sans[1]
210
+ sans = sans.split(/\s*,\s*/)
211
+ .collect { |s| s.split /\s*:\s*/ }
212
+ .select { |t, _| t == 'DNS' }
213
+ .collect { |_, v| v }
214
+ domains.concat sans
215
+ end
216
+ end
217
+
218
+ domains.uniq
219
+ end
220
+
221
+ def authorize(authorization)
222
+ domain = authorization.domain
223
+ challenge = authorization.http
224
+ status = challenge.status
225
+ if status == 'valid'
226
+ puts "Domain #{domain.colorize :green} already authorized"
227
+ return
228
+ end
229
+ puts "Authorizing domain #{domain.colorize :yellow} (current status: #{status.colorize :yellow})"
230
+
231
+ unless Dir.exists? @challenge_dir
232
+ self.process "Creating directory #{@challenge_dir}" do
233
+ FileUtils.mkdir_p @challenge_dir
234
+ end
235
+ end
236
+
237
+ filename = challenge.token
238
+ file = File.join @challenge_dir, filename
239
+ content = challenge.file_content
240
+ self.process "Writing challenge for #{domain.colorize :yellow} into #{file.colorize :yellow}" do
241
+ File.write file, content
242
+ end
243
+
244
+ url = "http://#{domain}/.well-known/acme-challenge/#{filename}"
245
+ self.process "Test challenge for #{url.colorize :yellow}" do
246
+ response = begin
247
+ Faraday.new do |conn|
248
+ conn.use FaradayMiddleware::FollowRedirects
249
+ conn.adapter Faraday.default_adapter
250
+ end.get url
251
+ rescue => e
252
+ raise Exception, e.message
253
+ end
254
+ raise Exception, "Got response code #{response.status.to_s.colorize :red}" unless response.success?
255
+ real_content = response.body
256
+ raise Exception, "Got #{real_content.colorize :red}, expected #{content.colorize :green}" unless real_content == content
257
+ end
258
+
259
+ self.process "Authorizing domain #{domain.colorize :yellow}" do
260
+ challenge.request_validation
261
+ status = nil
262
+ 60.times do
263
+ sleep 1
264
+ challenge.reload
265
+ status = challenge.status
266
+ break if status != 'pending'
267
+ end
268
+
269
+ raise Exception, "Got status #{status.colorize :red} instead of valid" unless status == 'valid'
270
+ end
271
+
272
+ File.unlink file
273
+ end
274
+
275
+ def internal_generate_crt(crt, csr: nil)
276
+ csr = crt unless csr
277
+ csr_file = csr
278
+ csr = OpenSSL::X509::Request.new File.read csr
279
+ domains = self.domains csr
280
+
281
+ order = client.new_order identifiers: domains
282
+ order.authorizations.each { |a| self.authorize a }
283
+
284
+ crt = self.process "Generating CRT #{crt} from CSR #{csr_file}" do
285
+ order.finalize csr: csr
286
+ certificate = order.certificate
287
+ File.write crt, certificate
288
+ OpenSSL::X509::Certificate.new certificate
289
+ end
290
+
291
+ self.certifificate_info crt
292
+ end
293
+
294
+ def humanize(secs)
295
+ [[60, :seconds], [60, :minutes], [24, :hours], [30, :days], [12, :months]].map { |count, name|
296
+ if secs > 0
297
+ secs, n = secs.divmod count
298
+ "#{n.to_i} #{name}"
299
+ end
300
+ }.compact.reverse.join(' ')
301
+ end
302
+ end
276
303
  end
@@ -5,7 +5,19 @@ module Acme
5
5
  module Information
6
6
  def key_info(key, tab: 0)
7
7
  key = open(key, 'r') { |f| OpenSSL::PKey.read f } unless key.is_a? OpenSSL::PKey::PKey
8
- der = key.to_der
8
+
9
+ der = case key
10
+ when OpenSSL::PKey::EC
11
+ puts "\t" * (tab) + "#{'Key'.colorize :red} : ECC #{key.group.curve_name}"
12
+
13
+ point = key.public_key
14
+ pub = OpenSSL::PKey::EC.new point.group
15
+ pub.public_key = point
16
+ pub
17
+ when OpenSSL::PKey::RSA
18
+ puts "\t" * (tab) + "#{'Key'.colorize :red} : RSA #{key.n.num_bits} bits"
19
+ key.public_key
20
+ end.to_der
9
21
 
10
22
  fingerprint der, tab: tab
11
23
 
@@ -61,25 +73,25 @@ module Acme
61
73
  puts "Fetch certificate #{issuer} from #{uri}"
62
74
  file = Digest::MD5.hexdigest uri
63
75
  file = file File.join 'cache', file
64
- dir = File.dirname file
76
+ dir = File.dirname file
65
77
  FileUtils.mkpath dir unless Dir.exist? dir
66
- crt = if File.exist? file
67
- open(file, 'r') { |f| OpenSSL::X509::Certificate.new f }
68
- else
69
- crt = Faraday.get uri
70
- break unless crt.success?
71
- crt = crt.body
72
-
73
- crt = begin
74
- OpenSSL::X509::Certificate.new crt
75
- rescue
76
- pkcs7 = OpenSSL::PKCS7.new crt
77
- pkcs7.certificates.first
78
- end
79
-
80
- File.write file, crt.to_pem
81
- crt
82
- end
78
+ crt = if File.exist? file
79
+ open(file, 'r') { |f| OpenSSL::X509::Certificate.new f }
80
+ else
81
+ crt = Faraday.get uri
82
+ break unless crt.success?
83
+ crt = crt.body
84
+
85
+ crt = begin
86
+ OpenSSL::X509::Certificate.new crt
87
+ rescue
88
+ pkcs7 = OpenSSL::PKCS7.new crt
89
+ pkcs7.certificates.first
90
+ end
91
+
92
+ File.write file, crt.to_pem
93
+ crt
94
+ end
83
95
 
84
96
  subject = crt.subject
85
97
  puts "Warning : expecting #{issuer}, get #{subject}".colorize :magenta unless subject == issuer
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acme-pki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aeris
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-04-24 00:00:00.000000000 Z
11
+ date: 2020-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -16,70 +16,98 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.0'
20
20
  type: :development
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: '1.11'
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: awesome_print
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry-byebug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.7'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.7'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: acme-client
29
57
  requirement: !ruby/object:Gem::Requirement
30
58
  requirements:
31
59
  - - "~>"
32
60
  - !ruby/object:Gem::Version
33
- version: 0.3.1
61
+ version: '2.0'
34
62
  type: :runtime
35
63
  prerelease: false
36
64
  version_requirements: !ruby/object:Gem::Requirement
37
65
  requirements:
38
66
  - - "~>"
39
67
  - !ruby/object:Gem::Version
40
- version: 0.3.1
68
+ version: '2.0'
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: faraday_middleware
43
71
  requirement: !ruby/object:Gem::Requirement
44
72
  requirements:
45
73
  - - "~>"
46
74
  - !ruby/object:Gem::Version
47
- version: 0.10.0
75
+ version: '0.13'
48
76
  type: :runtime
49
77
  prerelease: false
50
78
  version_requirements: !ruby/object:Gem::Requirement
51
79
  requirements:
52
80
  - - "~>"
53
81
  - !ruby/object:Gem::Version
54
- version: 0.10.0
82
+ version: '0.13'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: colorize
57
85
  requirement: !ruby/object:Gem::Requirement
58
86
  requirements:
59
87
  - - "~>"
60
88
  - !ruby/object:Gem::Version
61
- version: 0.7.7
89
+ version: '0.8'
62
90
  type: :runtime
63
91
  prerelease: false
64
92
  version_requirements: !ruby/object:Gem::Requirement
65
93
  requirements:
66
94
  - - "~>"
67
95
  - !ruby/object:Gem::Version
68
- version: 0.7.7
96
+ version: '0.8'
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: simpleidn
71
99
  requirement: !ruby/object:Gem::Requirement
72
100
  requirements:
73
101
  - - "~>"
74
102
  - !ruby/object:Gem::Version
75
- version: 0.0.7
103
+ version: '0.1'
76
104
  type: :runtime
77
105
  prerelease: false
78
106
  version_requirements: !ruby/object:Gem::Requirement
79
107
  requirements:
80
108
  - - "~>"
81
109
  - !ruby/object:Gem::Version
82
- version: 0.0.7
110
+ version: '0.1'
83
111
  description: Manage your keys, requests and certificates.
84
112
  email:
85
113
  - aeris@imirhil.fr
@@ -90,6 +118,7 @@ extra_rdoc_files: []
90
118
  files:
91
119
  - ".gitignore"
92
120
  - Gemfile
121
+ - Gemfile.lock
93
122
  - LICENSE
94
123
  - README.md
95
124
  - acme-pki.gemspec
@@ -97,11 +126,12 @@ files:
97
126
  - lib/acme/pki.rb
98
127
  - lib/acme/pki/information.rb
99
128
  - lib/acme/pki/monkey_patch.rb
129
+ - lib/acme/pki/version.rb
100
130
  homepage: https://github.com/aeris/acme-pki/
101
131
  licenses:
102
132
  - AGPL-3.0+
103
133
  metadata: {}
104
- post_install_message:
134
+ post_install_message:
105
135
  rdoc_options: []
106
136
  require_paths:
107
137
  - lib
@@ -116,9 +146,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
146
  - !ruby/object:Gem::Version
117
147
  version: '0'
118
148
  requirements: []
119
- rubyforge_project:
120
- rubygems_version: 2.5.1
121
- signing_key:
149
+ rubygems_version: 3.0.3
150
+ signing_key:
122
151
  specification_version: 4
123
- summary: Ruby client for Lets Encrypt
152
+ summary: Ruby client for Let's Encrypt
124
153
  test_files: []