acme-pki 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,87 @@
1
+ # Acme/PKI
2
+
3
+ Tiny PKI based on [Acme/client](https://github.com/unixcharles/acme-client).
4
+
5
+ Licensed under [AGPLv3+](https://www.gnu.org/licenses/agpl-3.0.en.html).
6
+
7
+ ## Usage
8
+ ### Registration
9
+
10
+ Usage: letsencrypt register <email>
11
+
12
+ ### Generate secret key
13
+
14
+ Usage: letsencrypt key <domain> [options]
15
+ -r, --rsa [KEYSIZE] RSA key, key size
16
+ -e, --ecc [CURVE] ECC key, curve
17
+
18
+ Generate a key (default is an EC secp384r1 key) in `example.bar.foo.pem`
19
+
20
+ letsencrypt key foo.bar.example
21
+
22
+ Default key is an EC secp384r1.
23
+
24
+ ### Generate certificate request
25
+
26
+ Usage: letsencrypt csr <domain> [options]
27
+ -k, --key [KEYFILE] Key file
28
+ -d, --domains [DOMAINS] Domains
29
+
30
+ Generate a certificate request in `example.bar.foo.csr`
31
+
32
+ letsencrypt csr foo.bar.example
33
+
34
+ If you have multiple domains
35
+
36
+ letsencrypt csr foo.example -d bar.example -d baz.example
37
+
38
+ ### Request certificate
39
+
40
+ Usage: letsencrypt crt <domain> [options]
41
+ -c, --csr [CSR] CSR file
42
+
43
+ Request the corresponding certificate in `example.bar.foo.crt`
44
+
45
+ letsencrypt crt foo.bar.example
46
+
47
+ You can call directly the certificate issuance, CSR and key will be created when needed.
48
+
49
+ ### Renew certificate
50
+
51
+ Usage: letsencrypt renew <domain> [options]
52
+ -c, --csr [CSR] CSR file
53
+
54
+ Renew the `example.bar.foo.crt` if needed (default is 30d before expiration).
55
+
56
+ letsencrypt renew foo.bar.example
57
+
58
+ If certificate was renewed, return code is 0 else 1, for post-action on crontab for example
59
+
60
+ #!/bin/bash
61
+ cd /etc/ssl/private
62
+
63
+ if letsencrypt renew foo.bar.example; then
64
+ service apache2 reload
65
+ fi
66
+
67
+ ### Get information from key or certificate
68
+
69
+ letsencrypt info <domain> [options]
70
+ -k, --key Key information
71
+ -c, --crt Certificate information
72
+
73
+ Display various information (fingerprints, HPKP, TLSA…) for key or certificate.
74
+
75
+ letsencrypt info foo.bar.example
76
+ letsencrypt info -c foo.bar.example
77
+
78
+ ## Environment variables
79
+
80
+ You can define which ACME endpoint is used with `ACME_ENDPOINT` environment variable.
81
+ Default is Let’s encrypt production endpoint (`https://acme-v01.api.letsencrypt.org/`).
82
+ You can use Let’s encrypt staging endpoint (`https://acme-staging.api.letsencrypt.org/`) for testing.
83
+
84
+ Default account key is `account.key` in the current directory. You can specify another key file with `ACME_ACCOUNT_KEY` environment variable.
85
+
86
+ Default ACME challenge directory is `acme-challenge` in the current directory.
87
+ You can change it with `ACME_CHALLENGE` environment variable.
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'acme-pki'
7
+ spec.version = '0.1.3'
8
+ spec.authors = ['Aeris']
9
+ spec.email = ['aeris@imirhil.fr']
10
+ spec.summary = %q{Ruby client for Let’s Encrypt}
11
+ spec.description = %q{Manage your keys, requests and certificates.}
12
+ spec.homepage = 'https://github.com/aeris/acme-pki/'
13
+ spec.license = 'AGPL-3.0+'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename f }
17
+ spec.test_files = spec.files.grep %r{^(test|spec|features)/}
18
+ spec.require_paths = %w(lib)
19
+
20
+ spec.add_development_dependency 'bundler', '~> 1.11'
21
+
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'
26
+ end
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ require 'acme/pki'
3
+
4
+ pki = Acme::PKI.new
5
+
6
+ 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
79
+ end
@@ -0,0 +1,276 @@
1
+ require 'acme/client'
2
+ require 'base64'
3
+ require 'colorize'
4
+ require 'digest'
5
+ require 'faraday_middleware'
6
+ require 'openssl'
7
+ require 'optparse'
8
+ require 'ostruct'
9
+ require 'simpleidn'
10
+
11
+ require 'acme/pki/monkey_patch'
12
+ require 'acme/pki/information'
13
+
14
+ 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
276
+ end