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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +2 -0
- data/LICENSE +661 -0
- data/README.md +87 -0
- data/acme-pki.gemspec +26 -0
- data/bin/letsencrypt +79 -0
- data/lib/acme/pki.rb +276 -0
- data/lib/acme/pki/information.rb +112 -0
- data/lib/acme/pki/monkey_patch.rb +1 -0
- metadata +124 -0
data/README.md
ADDED
@@ -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.
|
data/acme-pki.gemspec
ADDED
@@ -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
|
data/bin/letsencrypt
ADDED
@@ -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
|
data/lib/acme/pki.rb
ADDED
@@ -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
|