bullion 0.1.2 → 0.3.0
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/.roxanne.yml +14 -0
- data/.rubocop.yml +25 -6
- data/.ruby-version +1 -0
- data/.travis.yml +2 -1
- data/Dockerfile +2 -2
- data/Gemfile +1 -1
- data/Gemfile.lock +99 -89
- data/README.md +2 -2
- data/Rakefile +40 -37
- data/bin/console +3 -3
- data/bullion.gemspec +38 -36
- data/config/puma.rb +1 -1
- data/config.ru +5 -5
- data/db/migrate/20210104060422_create_certificates.rb +1 -1
- data/db/migrate/20210105060406_create_orders.rb +1 -1
- data/db/migrate/20210106052306_create_authorizations.rb +1 -1
- data/db/schema.rb +20 -21
- data/lib/bullion/acme/error.rb +9 -9
- data/lib/bullion/challenge_client.rb +4 -4
- data/lib/bullion/challenge_clients/dns.rb +36 -21
- data/lib/bullion/challenge_clients/http.rb +12 -8
- data/lib/bullion/helpers/acme.rb +30 -40
- data/lib/bullion/helpers/service.rb +2 -2
- data/lib/bullion/helpers/ssl.rb +50 -42
- data/lib/bullion/models/account.rb +1 -1
- data/lib/bullion/models/certificate.rb +2 -2
- data/lib/bullion/models/challenge.rb +5 -5
- data/lib/bullion/models/nonce.rb +1 -1
- data/lib/bullion/models.rb +6 -6
- data/lib/bullion/rspec/challenge_clients/dns.rb +22 -0
- data/lib/bullion/rspec/challenge_clients/http.rb +16 -0
- data/lib/bullion/service.rb +3 -2
- data/lib/bullion/services/ca.rb +107 -91
- data/lib/bullion/services/ping.rb +6 -6
- data/lib/bullion/version.rb +3 -3
- data/lib/bullion.rb +58 -45
- data/scripts/build.sh +3 -0
- data/scripts/release.sh +9 -0
- data/scripts/test.sh +6 -0
- metadata +65 -30
data/lib/bullion/helpers/ssl.rb
CHANGED
@@ -6,24 +6,24 @@ module Bullion
|
|
6
6
|
module Ssl
|
7
7
|
# Converts the incoming key data to an OpenSSL public key usable to verify JWT signatures
|
8
8
|
def openssl_compat(key_data)
|
9
|
-
case key_data[
|
10
|
-
when
|
9
|
+
case key_data["kty"]
|
10
|
+
when "RSA"
|
11
11
|
key_data_to_rsa(key_data)
|
12
|
-
when
|
12
|
+
when "EC"
|
13
13
|
key_data_to_ecdsa(key_data)
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
17
|
def openssl_compat_csr(csrdata)
|
18
18
|
"-----BEGIN CERTIFICATE REQUEST-----\n" \
|
19
|
-
|
19
|
+
"#{csrdata}-----END CERTIFICATE REQUEST-----"
|
20
20
|
end
|
21
21
|
|
22
22
|
# @see https://tools.ietf.org/html/rfc7518#page-30
|
23
23
|
def key_data_to_rsa(key_data)
|
24
24
|
key = OpenSSL::PKey::RSA.new
|
25
|
-
exponent = key_data[
|
26
|
-
modulus = key_data[
|
25
|
+
exponent = key_data["e"]
|
26
|
+
modulus = key_data["n"]
|
27
27
|
|
28
28
|
key.set_key(
|
29
29
|
base64_to_long(modulus),
|
@@ -35,14 +35,14 @@ module Bullion
|
|
35
35
|
|
36
36
|
def key_data_to_ecdsa(key_data)
|
37
37
|
crv_mapping = {
|
38
|
-
|
39
|
-
|
40
|
-
|
38
|
+
"P-256" => "prime256v1",
|
39
|
+
"P-384" => "secp384r1",
|
40
|
+
"P-521" => "secp521r1"
|
41
41
|
}
|
42
42
|
|
43
|
-
key = OpenSSL::PKey::EC.new(crv_mapping[key_data[
|
44
|
-
x = base64_to_octet(key_data[
|
45
|
-
y = base64_to_octet(key_data[
|
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
46
|
|
47
47
|
key_bn = OpenSSL::BN.new("\x04#{x}#{y}", 2)
|
48
48
|
key.public_key = OpenSSL::PKey::EC::Point.new(key.group, key_bn)
|
@@ -50,7 +50,7 @@ module Bullion
|
|
50
50
|
end
|
51
51
|
|
52
52
|
def base64_to_long(data)
|
53
|
-
Base64.urlsafe_decode64(data).to_s.unpack(
|
53
|
+
Base64.urlsafe_decode64(data).to_s.unpack("C*").map do |byte|
|
54
54
|
to_hex(byte)
|
55
55
|
end.join.to_i(16)
|
56
56
|
end
|
@@ -60,12 +60,12 @@ module Bullion
|
|
60
60
|
end
|
61
61
|
|
62
62
|
def digest_from_alg(alg)
|
63
|
-
if alg.end_with?(
|
64
|
-
OpenSSL::Digest.new(
|
65
|
-
elsif alg.end_with?(
|
66
|
-
OpenSSL::Digest.new(
|
63
|
+
if alg.end_with?("256")
|
64
|
+
OpenSSL::Digest.new("SHA256")
|
65
|
+
elsif alg.end_with?("384")
|
66
|
+
OpenSSL::Digest.new("SHA384")
|
67
67
|
else
|
68
|
-
OpenSSL::Digest.new(
|
68
|
+
OpenSSL::Digest.new("SHA512")
|
69
69
|
end
|
70
70
|
end
|
71
71
|
|
@@ -78,7 +78,7 @@ module Bullion
|
|
78
78
|
r = joined_ints[0..31]
|
79
79
|
s = joined_ints[32..]
|
80
80
|
# Unpack each word to a hex string
|
81
|
-
hexnums = [r, s].map { |n| n.unpack1(
|
81
|
+
hexnums = [r, s].map { |n| n.unpack1("H*") }
|
82
82
|
# Convert each to an Integer
|
83
83
|
ints = hexnums.map { |bn| bn.to_i(16) }
|
84
84
|
# Convert each Integer to a BigNumber
|
@@ -96,7 +96,7 @@ module Bullion
|
|
96
96
|
end
|
97
97
|
|
98
98
|
def simple_subject(common_name)
|
99
|
-
OpenSSL::X509::Name.new([[
|
99
|
+
OpenSSL::X509::Name.new([["CN", common_name, OpenSSL::ASN1::UTF8STRING]])
|
100
100
|
end
|
101
101
|
|
102
102
|
def manage_csr_extensions(csr, new_cert)
|
@@ -105,16 +105,16 @@ module Bullion
|
|
105
105
|
ef.subject_certificate = new_cert
|
106
106
|
ef.issuer_certificate = Bullion.ca_cert
|
107
107
|
new_cert.add_extension(
|
108
|
-
ef.create_extension(
|
108
|
+
ef.create_extension("basicConstraints", "CA:FALSE", true)
|
109
109
|
)
|
110
110
|
new_cert.add_extension(
|
111
|
-
ef.create_extension(
|
111
|
+
ef.create_extension("keyUsage", "keyEncipherment,dataEncipherment,digitalSignature", true)
|
112
112
|
)
|
113
113
|
new_cert.add_extension(
|
114
|
-
ef.create_extension(
|
114
|
+
ef.create_extension("subjectKeyIdentifier", "hash")
|
115
115
|
)
|
116
116
|
new_cert.add_extension(
|
117
|
-
ef.create_extension(
|
117
|
+
ef.create_extension("extendedKeyUsage", "serverAuth")
|
118
118
|
)
|
119
119
|
|
120
120
|
# Alternate Names
|
@@ -122,7 +122,7 @@ module Bullion
|
|
122
122
|
existing_sans = filter_sans(csr_sans(csr))
|
123
123
|
valid_alts = (["DNS:#{cn}"] + [*existing_sans]).uniq
|
124
124
|
|
125
|
-
new_cert.add_extension(ef.create_extension(
|
125
|
+
new_cert.add_extension(ef.create_extension("subjectAltName", valid_alts.join(",")))
|
126
126
|
|
127
127
|
# return the updated cert and any subject alternate names added
|
128
128
|
[new_cert, valid_alts]
|
@@ -132,7 +132,7 @@ module Bullion
|
|
132
132
|
raw_attributes = csr.attributes
|
133
133
|
return [] unless raw_attributes
|
134
134
|
|
135
|
-
seq =
|
135
|
+
seq = extract_csr_attrs(csr)
|
136
136
|
return [] unless seq
|
137
137
|
|
138
138
|
values = extract_san_values(seq)
|
@@ -143,37 +143,45 @@ module Bullion
|
|
143
143
|
values.select { |v| v.tag == 2 }.map { |v| "DNS:#{v.value}" }
|
144
144
|
end
|
145
145
|
|
146
|
-
def extract_csr_attrs(
|
147
|
-
|
146
|
+
def extract_csr_attrs(csr)
|
147
|
+
csr.attributes.select { |a| a.oid == "extReq" }.map { |a| a.value.map(&:value) }
|
148
|
+
end
|
149
|
+
|
150
|
+
def extract_csr_sans(csr_attrs)
|
151
|
+
csr_attrs.flatten.select { |a| a.value.first.value == "subjectAltName" }
|
152
|
+
end
|
153
|
+
|
154
|
+
def extract_csr_domains(csr_sans)
|
155
|
+
csr_decoded_sans = OpenSSL::ASN1.decode(csr_sans.first.value[1].value)
|
156
|
+
csr_decoded_sans.select { |v| v.tag == 2 }.map(&:value)
|
148
157
|
end
|
149
158
|
|
150
159
|
def extract_san_values(sequence)
|
160
|
+
unpacked_sequence = sequence
|
161
|
+
unpacked_sequence = unpacked_sequence.first while unpacked_sequence.first.is_a?(Array)
|
151
162
|
seqvalues = nil
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
seqvalues = innerv.value[1].value
|
156
|
-
break
|
157
|
-
end
|
158
|
-
break if seqvalues
|
159
|
-
end
|
163
|
+
unpacked_sequence.each do |outer_value|
|
164
|
+
seqvalues = outer_value.value[1].value if outer_value.value[0].value == "subjectAltName"
|
165
|
+
break if seqvalues
|
160
166
|
end
|
161
167
|
seqvalues
|
162
168
|
end
|
163
169
|
|
164
170
|
def filter_sans(potential_sans)
|
165
171
|
# Select only those that are part of the appropriate domain
|
166
|
-
potential_sans.select
|
172
|
+
potential_sans.select do |alt|
|
173
|
+
CA_DOMAINS.filter_map { |domain| alt.end_with?(".#{domain}") }.any?
|
174
|
+
end
|
167
175
|
end
|
168
176
|
|
169
177
|
def cn_from_csr(csr)
|
170
178
|
if csr.subject.to_s
|
171
|
-
cns = csr.subject.to_s.split(
|
179
|
+
cns = csr.subject.to_s.split("/").grep(/^CN=/)
|
172
180
|
|
173
|
-
return cns.first.split(
|
181
|
+
return cns.first.split("=").last if cns && !cns.empty?
|
174
182
|
end
|
175
183
|
|
176
|
-
csr_sans(csr).first.split(
|
184
|
+
csr_sans(csr).first.split(":").last
|
177
185
|
end
|
178
186
|
|
179
187
|
# Signs an ACME CSR
|
@@ -191,14 +199,14 @@ module Bullion
|
|
191
199
|
# Force a subject if the cert doesn't have one
|
192
200
|
cert.subject = simple_subject(cn_from_csr(csr)) unless cert.subject
|
193
201
|
|
194
|
-
csr_cert.subject = cert.subject.to_s
|
202
|
+
csr_cert.subject = simple_subject(cert.subject.to_s)
|
195
203
|
|
196
204
|
csr_cert.public_key = csr.public_key
|
197
205
|
csr_cert.issuer = Bullion.ca_cert.issuer
|
198
206
|
|
199
207
|
csr_cert, sans = manage_csr_extensions(csr, csr_cert)
|
200
208
|
|
201
|
-
csr_cert.sign(Bullion.ca_key, OpenSSL::Digest.new(
|
209
|
+
csr_cert.sign(Bullion.ca_key, OpenSSL::Digest.new("SHA256"))
|
202
210
|
|
203
211
|
cert.data = csr_cert.to_pem
|
204
212
|
cert.alternate_names = sans unless sans.empty?
|
@@ -11,7 +11,7 @@ module Bullion
|
|
11
11
|
validates_presence_of :subject
|
12
12
|
|
13
13
|
def init_values
|
14
|
-
self.serial ||= SecureRandom.hex(
|
14
|
+
self.serial ||= SecureRandom.hex(4).to_i(16)
|
15
15
|
end
|
16
16
|
|
17
17
|
def fingerprint
|
@@ -19,7 +19,7 @@ module Bullion
|
|
19
19
|
end
|
20
20
|
|
21
21
|
def cn
|
22
|
-
subject.split(
|
22
|
+
subject.split("/").grep(/^CN=/).first.split("=").last
|
23
23
|
end
|
24
24
|
|
25
25
|
def self.from_csr(csr)
|
@@ -17,16 +17,16 @@ module Bullion
|
|
17
17
|
end
|
18
18
|
|
19
19
|
def thumbprint
|
20
|
-
cipher = OpenSSL::Digest.new(
|
20
|
+
cipher = OpenSSL::Digest.new("SHA256")
|
21
21
|
cipher.hexdigest authorization.order.account.public_key.to_json
|
22
22
|
end
|
23
23
|
|
24
24
|
def client
|
25
25
|
case acme_type
|
26
|
-
when
|
27
|
-
|
28
|
-
when
|
29
|
-
|
26
|
+
when "dns-01"
|
27
|
+
DNS_CHALLENGE_CLIENT.new(self)
|
28
|
+
when "http-01"
|
29
|
+
HTTP_CHALLENGE_CLIENT.new(self)
|
30
30
|
else
|
31
31
|
raise Bullion::Acme::Errors::UnsupportedChallengeType,
|
32
32
|
"Challenge type '#{acme_type}' is not supported by Bullion."
|
data/lib/bullion/models/nonce.rb
CHANGED
data/lib/bullion/models.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
8
|
-
require
|
3
|
+
require "bullion/models/account"
|
4
|
+
require "bullion/models/authorization"
|
5
|
+
require "bullion/models/certificate"
|
6
|
+
require "bullion/models/challenge"
|
7
|
+
require "bullion/models/nonce"
|
8
|
+
require "bullion/models/order"
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module RSpec
|
5
|
+
module ChallengeClients
|
6
|
+
# A test DNS challenge client resolver for RSpec
|
7
|
+
class DNS < ::Bullion::ChallengeClients::DNS
|
8
|
+
FakeDNSRecord = Struct.new("FakeDNSRecord", :strings)
|
9
|
+
|
10
|
+
def records_for(name, _nameserver = nil)
|
11
|
+
return [] unless name == "_acme-challenge.#{identifier}"
|
12
|
+
|
13
|
+
[
|
14
|
+
FakeDNSRecord.new(
|
15
|
+
digest_value("#{challenge.token}.#{challenge.thumbprint}")
|
16
|
+
)
|
17
|
+
]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bullion
|
4
|
+
module RSpec
|
5
|
+
module ChallengeClients
|
6
|
+
# A test HTTP challenge client resolver for RSpec
|
7
|
+
class HTTP < ::Bullion::ChallengeClients::HTTP
|
8
|
+
def retrieve_body(url)
|
9
|
+
return "" unless url == "http://#{identifier}/.well-known/acme-challenge/#{challenge.token}"
|
10
|
+
|
11
|
+
"#{challenge.token}.#{challenge.thumbprint}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/bullion/service.rb
CHANGED
@@ -15,12 +15,13 @@ module Bullion
|
|
15
15
|
|
16
16
|
before do
|
17
17
|
# Sets up a useful variable (@json_body) for accessing a parsed request body
|
18
|
-
if request.content_type&.include?(
|
18
|
+
if request.content_type&.include?("json") && !request.body.read.empty?
|
19
|
+
p request.body
|
19
20
|
request.body.rewind
|
20
21
|
@json_body = JSON.parse(request.body.read, symbolize_names: true)
|
21
22
|
end
|
22
23
|
rescue StandardError => e
|
23
|
-
halt(400, { error: "Request must be JSON: #{e.message}" }.to_json)
|
24
|
+
halt(400, { error: "Request must be JSON: #{e.message}}" }.to_json)
|
24
25
|
end
|
25
26
|
end
|
26
27
|
end
|