bullion 0.1.0 → 0.1.2
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 +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
|