bullion 0.1.3 → 0.3.1

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.roxanne.yml +14 -0
  3. data/.rspec +0 -1
  4. data/.rubocop.yml +25 -6
  5. data/.ruby-version +1 -0
  6. data/Dockerfile +6 -16
  7. data/Gemfile +1 -1
  8. data/Gemfile.lock +99 -89
  9. data/README.md +2 -2
  10. data/Rakefile +57 -39
  11. data/bin/console +3 -3
  12. data/bullion.gemspec +38 -36
  13. data/config/puma.rb +1 -1
  14. data/config.ru +5 -5
  15. data/db/migrate/20210104060422_create_certificates.rb +1 -1
  16. data/db/migrate/20210105060406_create_orders.rb +1 -1
  17. data/db/migrate/20210106052306_create_authorizations.rb +1 -1
  18. data/db/schema.rb +20 -21
  19. data/lib/bullion/acme/error.rb +9 -9
  20. data/lib/bullion/challenge_client.rb +4 -4
  21. data/lib/bullion/challenge_clients/dns.rb +34 -22
  22. data/lib/bullion/challenge_clients/http.rb +12 -8
  23. data/lib/bullion/helpers/acme.rb +30 -40
  24. data/lib/bullion/helpers/service.rb +2 -2
  25. data/lib/bullion/helpers/ssl.rb +50 -42
  26. data/lib/bullion/models/account.rb +3 -3
  27. data/lib/bullion/models/authorization.rb +1 -1
  28. data/lib/bullion/models/certificate.rb +3 -3
  29. data/lib/bullion/models/challenge.rb +12 -5
  30. data/lib/bullion/models/nonce.rb +1 -1
  31. data/lib/bullion/models/order.rb +1 -1
  32. data/lib/bullion/models.rb +6 -6
  33. data/lib/bullion/rspec/challenge_clients/dns.rb +22 -0
  34. data/lib/bullion/rspec/challenge_clients/http.rb +16 -0
  35. data/lib/bullion/service.rb +3 -2
  36. data/lib/bullion/services/ca.rb +107 -91
  37. data/lib/bullion/services/ping.rb +6 -6
  38. data/lib/bullion/version.rb +3 -3
  39. data/lib/bullion.rb +58 -45
  40. data/scripts/build.sh +3 -0
  41. data/scripts/release.sh +9 -0
  42. data/scripts/test.sh +6 -0
  43. metadata +65 -30
@@ -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['kty']
10
- when 'RSA'
9
+ case key_data["kty"]
10
+ when "RSA"
11
11
  key_data_to_rsa(key_data)
12
- when 'EC'
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
- "#{csrdata}-----END CERTIFICATE REQUEST-----"
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['e']
26
- modulus = key_data['n']
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
- 'P-256' => 'prime256v1',
39
- 'P-384' => 'secp384r1',
40
- 'P-521' => 'secp521r1'
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['crv']])
44
- x = base64_to_octet(key_data['x'])
45
- y = base64_to_octet(key_data['y'])
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('C*').map do |byte|
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?('256')
64
- OpenSSL::Digest.new('SHA256')
65
- elsif alg.end_with?('384')
66
- OpenSSL::Digest.new('SHA384')
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('SHA512')
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('H*') }
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([['CN', common_name, OpenSSL::ASN1::UTF8STRING]])
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('basicConstraints', 'CA:FALSE', true)
108
+ ef.create_extension("basicConstraints", "CA:FALSE", true)
109
109
  )
110
110
  new_cert.add_extension(
111
- ef.create_extension('keyUsage', 'keyEncipherment,dataEncipherment,digitalSignature', true)
111
+ ef.create_extension("keyUsage", "keyEncipherment,dataEncipherment,digitalSignature", true)
112
112
  )
113
113
  new_cert.add_extension(
114
- ef.create_extension('subjectKeyIdentifier', 'hash')
114
+ ef.create_extension("subjectKeyIdentifier", "hash")
115
115
  )
116
116
  new_cert.add_extension(
117
- ef.create_extension('extendedKeyUsage', 'serverAuth')
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('subjectAltName', valid_alts.join(',')))
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 = extract_csr_seq(raw_attributes)
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(attrs)
147
- attrs.select { |a| a.oid == 'extReq' }.map(&:value).first
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
- 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
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 { |alt| alt.match(/#{CA_DOMAIN}$/) }
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('/').select { |name| name =~ /^CN=/ }
179
+ cns = csr.subject.to_s.split("/").grep(/^CN=/)
172
180
 
173
- return cns.first.split('=').last if cns && !cns.empty?
181
+ return cns.first.split("=").last if cns && !cns.empty?
174
182
  end
175
183
 
176
- csr_sans(csr).first.split(':').last
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('SHA256'))
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?
@@ -4,8 +4,8 @@ module Bullion
4
4
  module Models
5
5
  # ACMEv2 Account model
6
6
  class Account < ActiveRecord::Base
7
- serialize :contacts, Array
8
- serialize :public_key, Hash
7
+ serialize :contacts, JSON
8
+ serialize :public_key, JSON
9
9
 
10
10
  validates_uniqueness_of :public_key
11
11
 
@@ -20,7 +20,7 @@ module Bullion
20
20
  order.not_before = not_before if not_before
21
21
  order.not_after = not_after if not_after
22
22
  order.account = self
23
- order.status = 'pending'
23
+ order.status = "pending"
24
24
  order.identifiers = identifiers
25
25
  order.save
26
26
 
@@ -4,7 +4,7 @@ module Bullion
4
4
  module Models
5
5
  # ACMEv2 Authorization model
6
6
  class Authorization < ActiveRecord::Base
7
- serialize :identifier, Hash
7
+ serialize :identifier, JSON
8
8
 
9
9
  after_initialize :init_values, unless: :persisted?
10
10
 
@@ -4,14 +4,14 @@ module Bullion
4
4
  module Models
5
5
  # SSL Certificate model
6
6
  class Certificate < ActiveRecord::Base
7
- serialize :alternate_names
7
+ serialize :alternate_names, JSON
8
8
 
9
9
  after_initialize :init_values, unless: :persisted?
10
10
 
11
11
  validates_presence_of :subject
12
12
 
13
13
  def init_values
14
- self.serial ||= SecureRandom.hex(8).to_i(16)
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('/').select { |name| name =~ /^CN=/ }.first.split('=').last
22
+ subject.split("/").grep(/^CN=/).first.split("=").last
23
23
  end
24
24
 
25
25
  def self.from_csr(csr)
@@ -11,22 +11,29 @@ module Bullion
11
11
  validates :acme_type, inclusion: { in: %w[http-01 dns-01] }
12
12
  validates :status, inclusion: { in: %w[invalid pending processing valid] }
13
13
 
14
+ scope :dns01, -> { where(acme_type: "dns-01") }
15
+ scope :http01, -> { where(acme_type: "http-01") }
16
+
17
+ def identifier
18
+ authorization.identifier["value"]
19
+ end
20
+
14
21
  def init_values
15
22
  self.expires ||= Time.now + (60 * 60)
16
23
  self.token ||= SecureRandom.alphanumeric(48)
17
24
  end
18
25
 
19
26
  def thumbprint
20
- cipher = OpenSSL::Digest.new('SHA256')
27
+ cipher = OpenSSL::Digest.new("SHA256")
21
28
  cipher.hexdigest authorization.order.account.public_key.to_json
22
29
  end
23
30
 
24
31
  def client
25
32
  case acme_type
26
- when 'dns-01'
27
- ChallengeClients::DNS.new(self)
28
- when 'http-01'
29
- ChallengeClients::HTTP.new(self)
33
+ when "dns-01"
34
+ DNS_CHALLENGE_CLIENT.new(self)
35
+ when "http-01"
36
+ HTTP_CHALLENGE_CLIENT.new(self)
30
37
  else
31
38
  raise Bullion::Acme::Errors::UnsupportedChallengeType,
32
39
  "Challenge type '#{acme_type}' is not supported by Bullion."
@@ -15,7 +15,7 @@ module Bullion
15
15
  # Delete old nonces
16
16
  def self.clean_up!
17
17
  # nonces older than this can safely be deleted
18
- where('created_at < ?', Time.now - 86_400).delete_all
18
+ where("created_at < ?", Time.now - 86_400).delete_all
19
19
  end
20
20
  end
21
21
  end
@@ -4,7 +4,7 @@ module Bullion
4
4
  module Models
5
5
  # ACMEv2 Order model
6
6
  class Order < ActiveRecord::Base
7
- serialize :identifiers, Array
7
+ serialize :identifiers, JSON
8
8
 
9
9
  after_initialize :init_values, unless: :persisted?
10
10
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
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'
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
@@ -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?('json') && !request.body.to_s.empty?
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