bullion 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.images/logo.png +0 -0
  4. data/.rubocop.yml +32 -0
  5. data/.travis.yml +12 -4
  6. data/Dockerfile +55 -0
  7. data/Gemfile +4 -2
  8. data/Gemfile.lock +148 -0
  9. data/LICENSE.txt +1 -1
  10. data/README.md +48 -16
  11. data/Rakefile +88 -3
  12. data/bin/console +4 -3
  13. data/bullion.gemspec +38 -15
  14. data/config.ru +22 -0
  15. data/config/puma.rb +3 -0
  16. data/db/migrate/20210104000000_create_accounts.rb +14 -0
  17. data/db/migrate/20210104060422_create_certificates.rb +18 -0
  18. data/db/migrate/20210105060406_create_orders.rb +19 -0
  19. data/db/migrate/20210106052306_create_authorizations.rb +16 -0
  20. data/db/migrate/20210106055421_create_challenges.rb +18 -0
  21. data/db/migrate/20210106060335_create_nonces.rb +12 -0
  22. data/db/schema.rb +92 -0
  23. data/lib/bullion.rb +93 -2
  24. data/lib/bullion/acme/error.rb +72 -0
  25. data/lib/bullion/challenge_client.rb +59 -0
  26. data/lib/bullion/challenge_clients/dns.rb +49 -0
  27. data/lib/bullion/challenge_clients/http.rb +33 -0
  28. data/lib/bullion/helpers/acme.rb +202 -0
  29. data/lib/bullion/helpers/service.rb +17 -0
  30. data/lib/bullion/helpers/ssl.rb +214 -0
  31. data/lib/bullion/models.rb +8 -0
  32. data/lib/bullion/models/account.rb +33 -0
  33. data/lib/bullion/models/authorization.rb +31 -0
  34. data/lib/bullion/models/certificate.rb +37 -0
  35. data/lib/bullion/models/challenge.rb +37 -0
  36. data/lib/bullion/models/nonce.rb +22 -0
  37. data/lib/bullion/models/order.rb +39 -0
  38. data/lib/bullion/service.rb +26 -0
  39. data/lib/bullion/services/ca.rb +370 -0
  40. data/lib/bullion/services/ping.rb +36 -0
  41. data/lib/bullion/version.rb +7 -1
  42. data/scripts/docker-entrypoint.sh +9 -0
  43. 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