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.
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