bullion 0.1.0 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.images/logo.png +0 -0
- data/.rubocop.yml +32 -0
- data/.travis.yml +12 -4
- data/Dockerfile +55 -0
- data/Gemfile +4 -2
- data/Gemfile.lock +148 -0
- data/LICENSE.txt +1 -1
- data/README.md +48 -16
- data/Rakefile +88 -3
- data/bin/console +4 -3
- data/bullion.gemspec +38 -15
- data/config.ru +22 -0
- data/config/puma.rb +3 -0
- data/db/migrate/20210104000000_create_accounts.rb +14 -0
- data/db/migrate/20210104060422_create_certificates.rb +18 -0
- data/db/migrate/20210105060406_create_orders.rb +19 -0
- data/db/migrate/20210106052306_create_authorizations.rb +16 -0
- data/db/migrate/20210106055421_create_challenges.rb +18 -0
- data/db/migrate/20210106060335_create_nonces.rb +12 -0
- data/db/schema.rb +92 -0
- data/lib/bullion.rb +93 -2
- data/lib/bullion/acme/error.rb +72 -0
- data/lib/bullion/challenge_client.rb +59 -0
- data/lib/bullion/challenge_clients/dns.rb +49 -0
- data/lib/bullion/challenge_clients/http.rb +33 -0
- data/lib/bullion/helpers/acme.rb +202 -0
- data/lib/bullion/helpers/service.rb +17 -0
- data/lib/bullion/helpers/ssl.rb +214 -0
- data/lib/bullion/models.rb +8 -0
- data/lib/bullion/models/account.rb +33 -0
- data/lib/bullion/models/authorization.rb +31 -0
- data/lib/bullion/models/certificate.rb +37 -0
- data/lib/bullion/models/challenge.rb +37 -0
- data/lib/bullion/models/nonce.rb +22 -0
- data/lib/bullion/models/order.rb +39 -0
- data/lib/bullion/service.rb +26 -0
- data/lib/bullion/services/ca.rb +370 -0
- data/lib/bullion/services/ping.rb +36 -0
- data/lib/bullion/version.rb +7 -1
- data/scripts/docker-entrypoint.sh +9 -0
- metadata +302 -17
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
# Superclass for executing ACMEv2 Challenges
|
5
|
+
class ChallengeClient
|
6
|
+
ChallengeClientMetric = Prometheus::Client::Histogram.new(
|
7
|
+
:challenge_execution_seconds,
|
8
|
+
docstring: 'Challenge execution histogram in seconds',
|
9
|
+
labels: %i[acme_type status]
|
10
|
+
)
|
11
|
+
MetricsRegistry.register(ChallengeClientMetric)
|
12
|
+
|
13
|
+
attr_accessor :challenge
|
14
|
+
|
15
|
+
def initialize(challenge)
|
16
|
+
@challenge = challenge
|
17
|
+
end
|
18
|
+
|
19
|
+
# rubocop:disable Metrics/AbcSize
|
20
|
+
# rubocop:disable Metrics/MethodLength
|
21
|
+
def attempt(retries: 4)
|
22
|
+
tries = 0
|
23
|
+
success = false
|
24
|
+
|
25
|
+
benchtime = Benchmark.realtime do
|
26
|
+
until success || tries >= retries
|
27
|
+
tries += 1
|
28
|
+
success = perform
|
29
|
+
if success
|
30
|
+
LOGGER.info "Validated #{type} #{identifier}"
|
31
|
+
challenge.status = 'valid'
|
32
|
+
challenge.validated = Time.now
|
33
|
+
else
|
34
|
+
sleep rand(2..4)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
unless success
|
40
|
+
LOGGER.info "Failed to validate #{type} #{identifier}"
|
41
|
+
challenge.status = 'invalid'
|
42
|
+
end
|
43
|
+
|
44
|
+
challenge.save
|
45
|
+
|
46
|
+
ChallengeClientMetric.observe(
|
47
|
+
benchtime, labels: { acme_type: type, status: challenge.status }
|
48
|
+
)
|
49
|
+
|
50
|
+
success
|
51
|
+
end
|
52
|
+
# rubocop:enable Metrics/AbcSize
|
53
|
+
# rubocop:enable Metrics/MethodLength
|
54
|
+
|
55
|
+
def identifier
|
56
|
+
challenge.authorization.identifier['value']
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module ChallengeClients
|
5
|
+
# ACME DNS01 Challenge Client
|
6
|
+
# @see https://tools.ietf.org/html/rfc8555#section-8.4
|
7
|
+
class DNS < ChallengeClient
|
8
|
+
def type
|
9
|
+
'DNS01'
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform
|
13
|
+
value = dns_value
|
14
|
+
|
15
|
+
digester = OpenSSL::Digest.new('SHA256')
|
16
|
+
digest = digester.digest "#{challenge.token}.#{challenge.thumbprint}"
|
17
|
+
# clean up the digest output so it can match the provided challenge value
|
18
|
+
expected_value = Base64.urlsafe_encode64(digest).sub(/[\s=]*\z/, '')
|
19
|
+
|
20
|
+
value == expected_value
|
21
|
+
end
|
22
|
+
|
23
|
+
def dns_value
|
24
|
+
name = "_acme-challenge.#{identifier}"
|
25
|
+
|
26
|
+
# Randomly select a nameserver to pull the TXT record
|
27
|
+
nameserver = NAMESERVERS.sample
|
28
|
+
|
29
|
+
begin
|
30
|
+
records = Resolv::DNS.open(nameserver: nameserver) do |dns|
|
31
|
+
dns.getresources(
|
32
|
+
name,
|
33
|
+
Resolv::DNS::Resource::IN::TXT
|
34
|
+
)
|
35
|
+
end
|
36
|
+
record = records.map(&:strings).flatten.first
|
37
|
+
LOGGER.debug "Resolved #{name} to value #{record}"
|
38
|
+
record
|
39
|
+
rescue Resolv::ResolvError
|
40
|
+
LOGGER.info "Resolution error for #{name} via #{nameserver}"
|
41
|
+
false
|
42
|
+
rescue StandardError => e
|
43
|
+
LOGGER.warn "Error '#{e.message}' for #{name} with #{nameserver}"
|
44
|
+
false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module ChallengeClients
|
5
|
+
# ACME HTTP01 Challenge Client
|
6
|
+
# @see https://tools.ietf.org/html/rfc8555#section-8.3
|
7
|
+
class HTTP < ChallengeClient
|
8
|
+
def type
|
9
|
+
'HTTP01'
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform
|
13
|
+
response = begin
|
14
|
+
HTTParty.get(
|
15
|
+
challenge_url,
|
16
|
+
verify: false,
|
17
|
+
headers: { 'User-Agent' => "Bullion/#{Bullion::VERSION}" }
|
18
|
+
).body
|
19
|
+
rescue SocketError
|
20
|
+
LOGGER.debug "Failed to connect to #{challenge_url}"
|
21
|
+
''
|
22
|
+
end
|
23
|
+
|
24
|
+
token, thumbprint = response.split('.')
|
25
|
+
token == challenge.token && thumbprint == challenge.thumbprint
|
26
|
+
end
|
27
|
+
|
28
|
+
def challenge_url
|
29
|
+
"http://#{identifier}/.well-known/acme-challenge/#{challenge.token}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module Helpers
|
5
|
+
# ACME-specific helper functions
|
6
|
+
module Acme
|
7
|
+
# Parses and verifies the incoming ACME JWT for authentication
|
8
|
+
# @see https://tools.ietf.org/html/rfc8555#section-6.2
|
9
|
+
# rubocop:disable Metrics/AbcSize
|
10
|
+
# rubocop:disable Metrics/MethodLength
|
11
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
12
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
13
|
+
def parse_acme_jwt(key = nil, validate_nonce: true)
|
14
|
+
@header_data = extract_header_data
|
15
|
+
@payload_data = extract_payload_data
|
16
|
+
signature = @json_body[:signature]
|
17
|
+
|
18
|
+
# check nonce
|
19
|
+
if validate_nonce
|
20
|
+
nonce = Models::Nonce.where(token: @header_data['nonce']).first
|
21
|
+
raise Bullion::Acme::Errors::BadNonce unless nonce
|
22
|
+
|
23
|
+
nonce.destroy
|
24
|
+
end
|
25
|
+
|
26
|
+
jwt_data = [
|
27
|
+
@json_body[:protected],
|
28
|
+
@json_body[:payload],
|
29
|
+
@json_body[:signature]
|
30
|
+
].join('.')
|
31
|
+
|
32
|
+
# Either use the provided key or find the current user's public key
|
33
|
+
public_key = key || user_public_key
|
34
|
+
|
35
|
+
# Convert the key to an OpenSSL-compatible key
|
36
|
+
compat_public_key = openssl_compat(public_key)
|
37
|
+
|
38
|
+
# Validate the payload was signed with the private key for the public key
|
39
|
+
if @payload_data && @payload_data != ''
|
40
|
+
JWT.decode(jwt_data, compat_public_key, @header_data['alg'])
|
41
|
+
else
|
42
|
+
digest = digest_from_alg(@header_data['alg'])
|
43
|
+
|
44
|
+
sig = if @header_data['alg'].downcase.start_with?('es')
|
45
|
+
ecdsa_sig_to_der(signature)
|
46
|
+
elsif @header_data['alg'].downcase.start_with?('rs')
|
47
|
+
Base64.urlsafe_decode64(signature)
|
48
|
+
end
|
49
|
+
|
50
|
+
validated = compat_public_key.verify(
|
51
|
+
digest,
|
52
|
+
sig,
|
53
|
+
"#{@json_body[:protected]}."
|
54
|
+
)
|
55
|
+
raise Bullion::Acme::Errors::Malformed unless validated
|
56
|
+
end
|
57
|
+
end
|
58
|
+
# rubocop:enable Metrics/AbcSize
|
59
|
+
# rubocop:enable Metrics/MethodLength
|
60
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
61
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
62
|
+
|
63
|
+
def extract_header_data
|
64
|
+
JSON.parse(Base64.decode64(@json_body[:protected]))
|
65
|
+
end
|
66
|
+
|
67
|
+
def extract_payload_data
|
68
|
+
if @json_body[:payload] && @json_body[:payload] != ''
|
69
|
+
JSON.parse(Base64.decode64(@json_body[:payload]))
|
70
|
+
else
|
71
|
+
@json_body[:payload]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def user_public_key
|
76
|
+
@user = if @header_data['kid']
|
77
|
+
user_id = @header_data['kid'].split('/').last
|
78
|
+
return unless user_id
|
79
|
+
|
80
|
+
Models::Account.find(user_id)
|
81
|
+
else
|
82
|
+
Models::Account.where(public_key: @header_data['jwk']).last
|
83
|
+
end
|
84
|
+
|
85
|
+
@user.public_key
|
86
|
+
end
|
87
|
+
|
88
|
+
def extract_csr_attrs(csr)
|
89
|
+
csr.attributes.select { |a| a.oid == 'extReq' }.map { |a| a.value.map(&:value) }
|
90
|
+
end
|
91
|
+
|
92
|
+
def extract_csr_sans(csr_attrs)
|
93
|
+
csr_attrs.flatten.select { |a| a.value.first.value == 'subjectAltName' }
|
94
|
+
end
|
95
|
+
|
96
|
+
def extract_csr_domains(csr_sans)
|
97
|
+
csr_decoded_sans = OpenSSL::ASN1.decode(csr_sans.first.value[1].value)
|
98
|
+
csr_decoded_sans.select { |v| v.tag == 2 }.map(&:value)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Validation helpers
|
102
|
+
|
103
|
+
def validate_account_data(hash)
|
104
|
+
unless [true, false, nil].include?(hash['onlyReturnExisting'])
|
105
|
+
raise Bullion::Acme::Errors::Malformed,
|
106
|
+
"Invalid onlyReturnExisting: #{hash['onlyReturnExisting']}"
|
107
|
+
end
|
108
|
+
|
109
|
+
unless hash['contact'].is_a?(Array)
|
110
|
+
raise Bullion::Acme::Errors::InvalidContact,
|
111
|
+
"Invalid contacts format: #{hash['contact'].class}, #{hash}"
|
112
|
+
end
|
113
|
+
|
114
|
+
unless hash['contact'].size.positive?
|
115
|
+
raise Bullion::Acme::Errors::InvalidContact,
|
116
|
+
'Empty contacts list'
|
117
|
+
end
|
118
|
+
|
119
|
+
# Contacts must be a valid email
|
120
|
+
# TODO: find a better email verification approach
|
121
|
+
unless hash['contact'].reject { |c| c.match?(/^mailto:[a-zA-Z0-9@.+-]{3,}/) }.empty?
|
122
|
+
raise Bullion::Acme::Errors::UnsupportedContact
|
123
|
+
end
|
124
|
+
|
125
|
+
true
|
126
|
+
end
|
127
|
+
|
128
|
+
def validate_acme_csr(order, csr)
|
129
|
+
csr_attrs = extract_csr_attrs(csr)
|
130
|
+
csr_sans = extract_csr_sans(csr_attrs)
|
131
|
+
csr_domains = extract_csr_domains(csr_sans)
|
132
|
+
csr_cn = cn_from_csr(csr)
|
133
|
+
|
134
|
+
order_domains = order.identifiers.map { |i| i['value'] }
|
135
|
+
|
136
|
+
return false unless order.status == 'ready'
|
137
|
+
raise Bullion::Acme::Errors::BadCSR unless csr_domains.include?(csr_cn)
|
138
|
+
raise Bullion::Acme::Errors::BadCSR unless csr_domains.sort == order_domains.sort
|
139
|
+
|
140
|
+
true
|
141
|
+
end
|
142
|
+
|
143
|
+
def validate_order(hash)
|
144
|
+
validate_order_nb_and_na(hash['notBefore'], hash['notAfter'])
|
145
|
+
|
146
|
+
# Don't approve empty orders
|
147
|
+
raise Bullion::Acme::Errors::InvalidOrder, 'Empty order!' if hash['identifiers'].empty?
|
148
|
+
|
149
|
+
order_domains = hash['identifiers'].select { |ident| ident['type'] == 'dns' }
|
150
|
+
|
151
|
+
# Don't approve an order with identifiers that _aren't_ of type 'dns'
|
152
|
+
unless hash['identifiers'] == order_domains
|
153
|
+
raise Bullion::Acme::Errors::InvalidOrder, 'Only type "dns" allowed'
|
154
|
+
end
|
155
|
+
|
156
|
+
# Extract domains that end with something in our allowed domains list
|
157
|
+
valid_domains = order_domains.reject do |domain|
|
158
|
+
endings = CA_DOMAINS.select { |d| domain['value'].end_with?(d) }
|
159
|
+
endings.empty?
|
160
|
+
end
|
161
|
+
|
162
|
+
# Only allow CA_DOMAINS domains...
|
163
|
+
unless order_domains == valid_domains
|
164
|
+
raise(
|
165
|
+
Bullion::Acme::Errors::InvalidOrder,
|
166
|
+
"Domains #{order_domains - valid_domains} not allowed"
|
167
|
+
)
|
168
|
+
end
|
169
|
+
|
170
|
+
true
|
171
|
+
end
|
172
|
+
|
173
|
+
# rubocop:disable Metrics/AbcSize
|
174
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
175
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
176
|
+
def validate_order_nb_and_na(not_before, not_after)
|
177
|
+
raise Bullion::Acme::Errors::Malformed if not_before && !not_before.is_a?(String)
|
178
|
+
raise Bullion::Acme::Errors::Malformed if not_after && !not_after.is_a?(String)
|
179
|
+
|
180
|
+
return unless not_before && not_after
|
181
|
+
|
182
|
+
nb = Time.parse(not_before)
|
183
|
+
na = Time.parse(not_after)
|
184
|
+
|
185
|
+
# don't allow nonsense certs
|
186
|
+
raise Bullion::Acme::Errors::InvalidOrder unless nb < na
|
187
|
+
# don't allow far-future certs
|
188
|
+
if nb > Time.now + (7 * 86_400) || na > Time.now + CERT_VALIDITY_DURATION
|
189
|
+
raise Bullion::Acme::Errors::InvalidOrder
|
190
|
+
end
|
191
|
+
|
192
|
+
# don't allow really "old" certs
|
193
|
+
raise Bullion::Acme::Errors::InvalidOrder if nb < Time.now - (14 * 86_400)
|
194
|
+
# don't allow creating certs that are already expired
|
195
|
+
raise Bullion::Acme::Errors::InvalidOrder if na <= Time.now
|
196
|
+
end
|
197
|
+
# rubocop:enable Metrics/AbcSize
|
198
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
199
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module Helpers
|
5
|
+
# Sinatra service helper methods
|
6
|
+
module Service
|
7
|
+
def add_acme_headers(nonce, additional: {})
|
8
|
+
headers['Replay-Nonce'] = nonce
|
9
|
+
headers['Link'] = "<#{uri('/directory')}>;rel=\"index\""
|
10
|
+
|
11
|
+
additional.each do |name, value|
|
12
|
+
headers[name.to_s] = value.to_s
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module Helpers
|
5
|
+
# SSL-related helper methods
|
6
|
+
module Ssl
|
7
|
+
# Converts the incoming key data to an OpenSSL public key usable to verify JWT signatures
|
8
|
+
def openssl_compat(key_data)
|
9
|
+
case key_data['kty']
|
10
|
+
when 'RSA'
|
11
|
+
key_data_to_rsa(key_data)
|
12
|
+
when 'EC'
|
13
|
+
key_data_to_ecdsa(key_data)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def openssl_compat_csr(csrdata)
|
18
|
+
"-----BEGIN CERTIFICATE REQUEST-----\n" \
|
19
|
+
"#{csrdata}-----END CERTIFICATE REQUEST-----"
|
20
|
+
end
|
21
|
+
|
22
|
+
# @see https://tools.ietf.org/html/rfc7518#page-30
|
23
|
+
def key_data_to_rsa(key_data)
|
24
|
+
key = OpenSSL::PKey::RSA.new
|
25
|
+
exponent = key_data['e']
|
26
|
+
modulus = key_data['n']
|
27
|
+
|
28
|
+
key.set_key(
|
29
|
+
base64_to_long(modulus),
|
30
|
+
base64_to_long(exponent),
|
31
|
+
nil
|
32
|
+
)
|
33
|
+
key
|
34
|
+
end
|
35
|
+
|
36
|
+
def key_data_to_ecdsa(key_data)
|
37
|
+
crv_mapping = {
|
38
|
+
'P-256' => 'prime256v1',
|
39
|
+
'P-384' => 'secp384r1',
|
40
|
+
'P-521' => 'secp521r1'
|
41
|
+
}
|
42
|
+
|
43
|
+
key = OpenSSL::PKey::EC.new(crv_mapping[key_data['crv']])
|
44
|
+
x = base64_to_octet(key_data['x'])
|
45
|
+
y = base64_to_octet(key_data['y'])
|
46
|
+
|
47
|
+
key_bn = OpenSSL::BN.new("\x04#{x}#{y}", 2)
|
48
|
+
key.public_key = OpenSSL::PKey::EC::Point.new(key.group, key_bn)
|
49
|
+
key
|
50
|
+
end
|
51
|
+
|
52
|
+
def base64_to_long(data)
|
53
|
+
Base64.urlsafe_decode64(data).to_s.unpack('C*').map do |byte|
|
54
|
+
to_hex(byte)
|
55
|
+
end.join.to_i(16)
|
56
|
+
end
|
57
|
+
|
58
|
+
def base64_to_octet(data)
|
59
|
+
Base64.urlsafe_decode64(data)
|
60
|
+
end
|
61
|
+
|
62
|
+
def digest_from_alg(alg)
|
63
|
+
if alg.end_with?('256')
|
64
|
+
OpenSSL::Digest.new('SHA256')
|
65
|
+
elsif alg.end_with?('384')
|
66
|
+
OpenSSL::Digest.new('SHA384')
|
67
|
+
else
|
68
|
+
OpenSSL::Digest.new('SHA512')
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# This is for ECDSA keys.
|
73
|
+
# @see https://bit.ly/3b7yZFd
|
74
|
+
def ecdsa_sig_to_der(incoming)
|
75
|
+
# Base64 decode the signature
|
76
|
+
joined_ints = Base64.urlsafe_decode64(incoming)
|
77
|
+
# Break it apart into the two 32-byte words
|
78
|
+
r = joined_ints[0..31]
|
79
|
+
s = joined_ints[32..]
|
80
|
+
# Unpack each word to a hex string
|
81
|
+
hexnums = [r, s].map { |n| n.unpack1('H*') }
|
82
|
+
# Convert each to an Integer
|
83
|
+
ints = hexnums.map { |bn| bn.to_i(16) }
|
84
|
+
# Convert each Integer to a BigNumber
|
85
|
+
bns = ints.map { |int| OpenSSL::BN.new(int) }
|
86
|
+
# Wrap each BigNum in a ASN1 encoding
|
87
|
+
asn1_wrapped = bns.map { |bn| OpenSSL::ASN1::Integer.new(bn) }
|
88
|
+
# Create an ASN1 sequence from the ASN1-wrapped Integers
|
89
|
+
seq = OpenSSL::ASN1::Sequence.new(asn1_wrapped)
|
90
|
+
# Return the DER-encoded sequence for verification
|
91
|
+
seq.to_der
|
92
|
+
end
|
93
|
+
|
94
|
+
def to_hex(int)
|
95
|
+
int < 16 ? "0#{int.to_s(16)}" : int.to_s(16)
|
96
|
+
end
|
97
|
+
|
98
|
+
def simple_subject(common_name)
|
99
|
+
OpenSSL::X509::Name.new([['CN', common_name, OpenSSL::ASN1::UTF8STRING]])
|
100
|
+
end
|
101
|
+
|
102
|
+
def manage_csr_extensions(csr, new_cert)
|
103
|
+
# Build extensions
|
104
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
105
|
+
ef.subject_certificate = new_cert
|
106
|
+
ef.issuer_certificate = Bullion.ca_cert
|
107
|
+
new_cert.add_extension(
|
108
|
+
ef.create_extension('basicConstraints', 'CA:FALSE', true)
|
109
|
+
)
|
110
|
+
new_cert.add_extension(
|
111
|
+
ef.create_extension('keyUsage', 'keyEncipherment,dataEncipherment,digitalSignature', true)
|
112
|
+
)
|
113
|
+
new_cert.add_extension(
|
114
|
+
ef.create_extension('subjectKeyIdentifier', 'hash')
|
115
|
+
)
|
116
|
+
new_cert.add_extension(
|
117
|
+
ef.create_extension('extendedKeyUsage', 'serverAuth')
|
118
|
+
)
|
119
|
+
|
120
|
+
# Alternate Names
|
121
|
+
cn = cn_from_csr(csr)
|
122
|
+
existing_sans = filter_sans(csr_sans(csr))
|
123
|
+
valid_alts = (["DNS:#{cn}"] + [*existing_sans]).uniq
|
124
|
+
|
125
|
+
new_cert.add_extension(ef.create_extension('subjectAltName', valid_alts.join(',')))
|
126
|
+
|
127
|
+
# return the updated cert and any subject alternate names added
|
128
|
+
[new_cert, valid_alts]
|
129
|
+
end
|
130
|
+
|
131
|
+
def csr_sans(csr)
|
132
|
+
raw_attributes = csr.attributes
|
133
|
+
return [] unless raw_attributes
|
134
|
+
|
135
|
+
seq = extract_csr_seq(raw_attributes)
|
136
|
+
return [] unless seq
|
137
|
+
|
138
|
+
values = extract_san_values(seq)
|
139
|
+
return [] unless values
|
140
|
+
|
141
|
+
values = OpenSSL::ASN1.decode(values).value
|
142
|
+
|
143
|
+
values.select { |v| v.tag == 2 }.map { |v| "DNS:#{v.value}" }
|
144
|
+
end
|
145
|
+
|
146
|
+
def extract_csr_attrs(attrs)
|
147
|
+
attrs.select { |a| a.oid == 'extReq' }.map(&:value).first
|
148
|
+
end
|
149
|
+
|
150
|
+
def extract_san_values(sequence)
|
151
|
+
seqvalues = nil
|
152
|
+
sequence.value.each do |v|
|
153
|
+
v.each do |innerv|
|
154
|
+
if innerv.value[0].value == 'subjectAltName'
|
155
|
+
seqvalues = innerv.value[1].value
|
156
|
+
break
|
157
|
+
end
|
158
|
+
break if seqvalues
|
159
|
+
end
|
160
|
+
end
|
161
|
+
seqvalues
|
162
|
+
end
|
163
|
+
|
164
|
+
def filter_sans(potential_sans)
|
165
|
+
# Select only those that are part of the appropriate domain
|
166
|
+
potential_sans.select { |alt| alt.match(/#{CA_DOMAIN}$/) }
|
167
|
+
end
|
168
|
+
|
169
|
+
def cn_from_csr(csr)
|
170
|
+
if csr.subject.to_s
|
171
|
+
cns = csr.subject.to_s.split('/').select { |name| name =~ /^CN=/ }
|
172
|
+
|
173
|
+
return cns.first.split('=').last if cns && !cns.empty?
|
174
|
+
end
|
175
|
+
|
176
|
+
csr_sans(csr).first.split(':').last
|
177
|
+
end
|
178
|
+
|
179
|
+
# Signs an ACME CSR
|
180
|
+
# rubocop:disable Metrics/AbcSize
|
181
|
+
def sign_csr(csr, username)
|
182
|
+
cert = Models::Certificate.from_csr(csr)
|
183
|
+
# Create a OpenSSL cert using select info from the CSR
|
184
|
+
csr_cert = OpenSSL::X509::Certificate.new
|
185
|
+
csr_cert.serial = cert.serial
|
186
|
+
csr_cert.version = 2
|
187
|
+
csr_cert.not_before = Time.now
|
188
|
+
# only 90 days for ACMEv2
|
189
|
+
csr_cert.not_after = csr_cert.not_before + (3 * 30 * 24 * 60 * 60)
|
190
|
+
|
191
|
+
# Force a subject if the cert doesn't have one
|
192
|
+
cert.subject = simple_subject(cn_from_csr(csr)) unless cert.subject
|
193
|
+
|
194
|
+
csr_cert.subject = cert.subject.to_s
|
195
|
+
|
196
|
+
csr_cert.public_key = csr.public_key
|
197
|
+
csr_cert.issuer = Bullion.ca_cert.issuer
|
198
|
+
|
199
|
+
csr_cert, sans = manage_csr_extensions(csr, csr_cert)
|
200
|
+
|
201
|
+
csr_cert.sign(Bullion.ca_key, OpenSSL::Digest.new('SHA256'))
|
202
|
+
|
203
|
+
cert.data = csr_cert.to_pem
|
204
|
+
cert.alternate_names = sans unless sans.empty?
|
205
|
+
cert.requester = username
|
206
|
+
cert.validated = true
|
207
|
+
cert.save
|
208
|
+
|
209
|
+
[csr_cert, cert.id]
|
210
|
+
end
|
211
|
+
# rubocop:enable Metrics/AbcSize
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|