acme-pki 0.1.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.
@@ -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