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,8 @@
1
+ # frozen_string_literal: true
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'
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # ACMEv2 Account model
6
+ class Account < ActiveRecord::Base
7
+ serialize :contacts, Array
8
+ serialize :public_key, Hash
9
+
10
+ validates_uniqueness_of :public_key
11
+
12
+ has_many :orders
13
+
14
+ def kid
15
+ id
16
+ end
17
+
18
+ def start_order(identifiers:, not_before: nil, not_after: nil)
19
+ order = Order.new
20
+ order.not_before = not_before if not_before
21
+ order.not_after = not_after if not_after
22
+ order.account = self
23
+ order.status = 'pending'
24
+ order.identifiers = identifiers
25
+ order.save
26
+
27
+ order.prep_authorizations!
28
+
29
+ order
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # ACMEv2 Authorization model
6
+ class Authorization < ActiveRecord::Base
7
+ serialize :identifier, Hash
8
+
9
+ after_initialize :init_values, unless: :persisted?
10
+
11
+ belongs_to :order
12
+ has_many :challenges
13
+
14
+ validates :status, inclusion: { in: %w[invalid pending ready processing valid deactivated] }
15
+
16
+ def init_values
17
+ self.expires ||= Time.now + (60 * 60)
18
+ end
19
+
20
+ def prep_challenges!
21
+ %w[http-01 dns-01].each do |type|
22
+ chall = Challenge.new
23
+ chall.authorization = self
24
+ chall.acme_type = type
25
+
26
+ chall.save
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # SSL Certificate model
6
+ class Certificate < ActiveRecord::Base
7
+ serialize :alternate_names
8
+
9
+ after_initialize :init_values, unless: :persisted?
10
+
11
+ validates_presence_of :subject
12
+
13
+ def init_values
14
+ self.serial ||= SecureRandom.hex(8).to_i(16)
15
+ end
16
+
17
+ def fingerprint
18
+ Base64.encode64(OpenSSL::Digest::SHA1.digest(data))
19
+ end
20
+
21
+ def cn
22
+ subject.split('/').select { |name| name =~ /^CN=/ }.first.split('=').last
23
+ end
24
+
25
+ def self.from_csr(csr)
26
+ subjt = csr.subject if csr.subject && !csr.subject.to_s.empty?
27
+
28
+ cert = new(
29
+ csr_fingerprint: Base64.encode64(OpenSSL::Digest::SHA1.digest(csr.to_pem)).chomp
30
+ )
31
+
32
+ cert.subject = subjt if subjt
33
+ cert
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # ACMEv2 Challenge model
6
+ class Challenge < ActiveRecord::Base
7
+ after_initialize :init_values, unless: :persisted?
8
+
9
+ belongs_to :authorization
10
+
11
+ validates :acme_type, inclusion: { in: %w[http-01 dns-01] }
12
+ validates :status, inclusion: { in: %w[invalid pending processing valid] }
13
+
14
+ def init_values
15
+ self.expires ||= Time.now + (60 * 60)
16
+ self.token ||= SecureRandom.alphanumeric(48)
17
+ end
18
+
19
+ def thumbprint
20
+ cipher = OpenSSL::Digest.new('SHA256')
21
+ cipher.hexdigest authorization.order.account.public_key.to_json
22
+ end
23
+
24
+ def client
25
+ case acme_type
26
+ when 'dns-01'
27
+ ChallengeClients::DNS.new(self)
28
+ when 'http-01'
29
+ ChallengeClients::HTTP.new(self)
30
+ else
31
+ raise Bullion::Acme::Errors::UnsupportedChallengeType,
32
+ "Challenge type '#{acme_type}' is not supported by Bullion."
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # ACMEv2 Nonce model
6
+ class Nonce < ActiveRecord::Base
7
+ after_initialize :init_values, unless: :persisted?
8
+
9
+ validates_uniqueness_of :token
10
+
11
+ def init_values
12
+ self.token ||= SecureRandom.alphanumeric
13
+ end
14
+
15
+ # Delete old nonces
16
+ def self.clean_up!
17
+ # nonces older than this can safely be deleted
18
+ where('created_at < ?', Time.now - 86_400).delete_all
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Models
5
+ # ACMEv2 Order model
6
+ class Order < ActiveRecord::Base
7
+ serialize :identifiers, Array
8
+
9
+ after_initialize :init_values, unless: :persisted?
10
+
11
+ belongs_to :account
12
+ has_many :authorizations
13
+
14
+ validates :status, inclusion: { in: %w[invalid pending ready processing valid] }
15
+
16
+ def init_values
17
+ self.expires ||= Time.now + (60 * 60)
18
+ self.not_before ||= Time.now
19
+ self.not_after ||= Time.now + (60 * 60 * 24 * 90) # 90 days
20
+ end
21
+
22
+ def prep_authorizations!
23
+ identifiers.each do |identifier|
24
+ authorization = Authorization.new
25
+ authorization.order = self
26
+ authorization.identifier = identifier
27
+
28
+ authorization.save
29
+
30
+ authorization.prep_challenges!
31
+ end
32
+ end
33
+
34
+ def certificate
35
+ Certificate.find(certificate_id)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ # Parent class for API services
5
+ class Service < Sinatra::Base
6
+ register Sinatra::ActiveRecordExtension
7
+ helpers Sinatra::CustomLogger
8
+
9
+ configure do
10
+ set :protection, except: :http_origin
11
+ set :logging, true
12
+ set :logger, Bullion::LOGGER
13
+ set :database, DB_CONNECTION_SETTINGS
14
+ end
15
+
16
+ before do
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?
19
+ request.body.rewind
20
+ @json_body = JSON.parse(request.body.read, symbolize_names: true)
21
+ end
22
+ rescue StandardError => e
23
+ halt(400, { error: "Request must be JSON: #{e.message}" }.to_json)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullion
4
+ module Services
5
+ # ACME CA web service
6
+ class CA < Service
7
+ helpers Helpers::Acme
8
+ helpers Helpers::Service
9
+ helpers Helpers::Ssl
10
+
11
+ before do
12
+ Models::Nonce.clean_up! if rand(5) > 2 # randomly clean up
13
+ @new_nonce = Models::Nonce.create.token
14
+ end
15
+
16
+ after do
17
+ if request.options?
18
+ @allowed_types ||= ['POST']
19
+ headers 'Access-Control-Allow-Methods' => @allowed_types
20
+ end
21
+ end
22
+
23
+ options '/directory' do
24
+ @allowed_types = ['GET']
25
+ halt 200
26
+ end
27
+
28
+ options '/nonces' do
29
+ @allowed_types = %w[HEAD GET]
30
+ halt 200
31
+ end
32
+
33
+ options '/accounts' do
34
+ halt 200
35
+ end
36
+
37
+ options '/accounts/:id' do
38
+ halt 200
39
+ end
40
+
41
+ options '/accounts/:id/orders' do
42
+ halt 200
43
+ end
44
+
45
+ options '/orders' do
46
+ halt 200
47
+ end
48
+
49
+ options '/orders/:id' do
50
+ halt 200
51
+ end
52
+
53
+ options '/orders/:id/finalize' do
54
+ halt 200
55
+ end
56
+
57
+ options '/authorizations/:id' do
58
+ halt 200
59
+ end
60
+
61
+ options '/challenges/:id' do
62
+ halt 200
63
+ end
64
+
65
+ options '/certificates/:id' do
66
+ halt 200
67
+ end
68
+
69
+ # Non-standard endpoint that returns the CA bundle for Bullion
70
+ # Trusting this bundle should be sufficient to trust all Bullion-issued certs
71
+ options '/cabundle' do
72
+ @allowed_types = ['GET']
73
+ halt 200
74
+ end
75
+
76
+ # The directory is used to find all required URLs for the ACME endpoints
77
+ # @see https://tools.ietf.org/html/rfc8555#section-7.1.1
78
+ get '/directory' do
79
+ content_type 'application/json'
80
+
81
+ {
82
+ newNonce: uri('/nonces'),
83
+ newAccount: uri('/accounts'),
84
+ newOrder: uri('/orders'),
85
+ revokeCert: uri('/revokecert'),
86
+ keyChange: uri('/keychanges'),
87
+ # non-standard entries:
88
+ caBundle: uri('/cabundle')
89
+ }.to_json
90
+ end
91
+
92
+ # Responds with Bullion's PEM-encoded public cert
93
+ get '/cabundle' do
94
+ expires 3600 * 48, :public, :must_revalidate
95
+ content_type 'application/x-pem-file'
96
+
97
+ attachment 'cabundle.pem'
98
+ Bullion.ca_cert.to_pem
99
+ end
100
+
101
+ # Retrieves a Nonce via a HEAD request
102
+ # @see https://tools.ietf.org/html/rfc8555#section-7.2
103
+ head '/nonces' do
104
+ add_acme_headers @new_nonce, additional: { 'Cache-Control' => 'no-store' }
105
+
106
+ halt 200
107
+ end
108
+
109
+ # Retrieves a Nonce via a GET request
110
+ # @see https://tools.ietf.org/html/rfc8555#section-7.2
111
+ get '/nonces' do
112
+ add_acme_headers @new_nonce, additional: { 'Cache-Control' => 'no-store' }
113
+
114
+ halt 204
115
+ end
116
+
117
+ # Creates an account or verifies that an account exists
118
+ # @see https://tools.ietf.org/html/rfc8555#section-7.3
119
+ post '/accounts' do
120
+ header_data = JSON.parse(Base64.decode64(@json_body[:protected]))
121
+ begin
122
+ parse_acme_jwt(header_data['jwk'], validate_nonce: false)
123
+
124
+ validate_account_data(@payload_data)
125
+ rescue Bullion::Acme::Error => e
126
+ content_type 'application/problem+json'
127
+ halt 400, { type: e.acme_error, detail: e.message }.to_json
128
+ end
129
+
130
+ user = Models::Account.where(
131
+ public_key: header_data['jwk']
132
+ ).first
133
+
134
+ if @payload_data['onlyReturnExisting']
135
+ content_type 'application/problem+json'
136
+ halt 400, { type: 'urn:ietf:params:acme:error:accountDoesNotExist' }.to_json unless user
137
+ end
138
+
139
+ user ||= Models::Account.new(public_key: header_data['jwk'])
140
+ user.tos_agreed = true
141
+ user.contacts = @payload_data['contact']
142
+ user.save
143
+
144
+ content_type 'application/json'
145
+ add_acme_headers @new_nonce, additional: { 'Location' => uri("/accounts/#{user.id}") }
146
+
147
+ halt 201, {
148
+ 'status': user.tos_agreed? ? 'valid' : 'pending',
149
+ 'contact': user.contacts,
150
+ 'orders': uri("/accounts/#{user.id}/orders")
151
+ }.to_json
152
+ end
153
+
154
+ # Endpoint for updating accounts
155
+ # @see https://tools.ietf.org/html/rfc8555#section-7.3.2
156
+ post '/accounts/:id' do
157
+ parse_acme_jwt
158
+
159
+ unless params[:id] == @user.id
160
+ content_type 'application/json'
161
+ add_acme_headers @new_nonce
162
+
163
+ halt 403, { error: 'Accounts can only view or update themselves' }.to_json
164
+ end
165
+
166
+ content_type 'application/json'
167
+
168
+ {
169
+ 'status': 'valid',
170
+ 'orders': uri("/accounts/#{@user.id}/orders"),
171
+ 'contact': @user.contacts
172
+ }.to_json
173
+ end
174
+
175
+ post '/accounts/:id/orders' do
176
+ parse_acme_jwt
177
+
178
+ unless params[:id] == @user.id
179
+ content_type 'application/json'
180
+ add_acme_headers @new_nonce
181
+
182
+ halt 403, { error: 'Accounts can only view or update themselves' }.to_json
183
+ end
184
+
185
+ content_type 'application/json'
186
+ add_acme_headers @new_nonce
187
+
188
+ {
189
+ orders: @user.orders.map { |order| uri("/orders/#{order.id}") }
190
+ }
191
+ end
192
+
193
+ # Endpoint for creating new orders
194
+ # @see https://tools.ietf.org/html/rfc8555#section-7.4
195
+ post '/orders' do
196
+ parse_acme_jwt
197
+
198
+ # Only identifiers of type "dns" are supported
199
+ identifiers = @payload_data['identifiers'].select { |i| i['type'] == 'dns' }
200
+
201
+ validate_order(@payload_data)
202
+
203
+ order = @user.start_order(
204
+ identifiers: identifiers,
205
+ not_before: @payload_data['notBefore'],
206
+ not_after: @payload_data['notAfter']
207
+ )
208
+
209
+ content_type 'application/json'
210
+ add_acme_headers @new_nonce, additional: { 'Location' => uri("/orders/#{order.id}") }
211
+
212
+ halt 201, {
213
+ status: order.status,
214
+ expires: order.expires,
215
+ notBefore: order.not_before,
216
+ notAfter: order.not_after,
217
+ identifiers: order.identifiers,
218
+ authorizations: order.authorizations.map { |a| uri("/authorizations/#{a.id}") },
219
+ finalize: uri("/orders/#{order.id}/finalize")
220
+ }.to_json
221
+ rescue Bullion::Acme::Error => e
222
+ content_type 'application/problem+json'
223
+ halt 400, { type: e.acme_error, detail: e.message }.to_json
224
+ end
225
+
226
+ # Retrieve existing Orders
227
+ post '/orders/:id' do
228
+ parse_acme_jwt
229
+
230
+ content_type 'application/json'
231
+ add_acme_headers @new_nonce
232
+
233
+ order = Models::Order.find(params[:id])
234
+
235
+ data = {
236
+ status: order.status,
237
+ expires: order.expires,
238
+ notBefore: order.not_before,
239
+ notAfter: order.not_after,
240
+ identifiers: order.identifiers,
241
+ authorizations: order.authorizations.map { |a| uri("/authorizations/#{a.id}") },
242
+ finalize: uri("/orders/#{order.id}/finalize")
243
+ }
244
+
245
+ data[:certificate] = uri("/certificates/#{order.certificate.id}") if order.status == 'valid'
246
+
247
+ data.to_json
248
+ end
249
+
250
+ # Submit an order for finalization/signing
251
+ # @see https://tools.ietf.org/html/rfc8555#section-7.4
252
+ post '/orders/:id/finalize' do
253
+ parse_acme_jwt
254
+
255
+ content_type 'application/json'
256
+ add_acme_headers @new_nonce, additional: { 'Location' => uri("/orders/#{order.id}") }
257
+
258
+ raw_csr_data = Base64.urlsafe_decode64(@payload_data['csr'])
259
+ encoded_csr = Base64.encode64(raw_csr_data)
260
+
261
+ csr_data = openssl_compat_csr(encoded_csr)
262
+
263
+ csr = OpenSSL::X509::Request.new(csr_data)
264
+
265
+ order = Models::Order.find(params[:id])
266
+
267
+ unless validate_csr(csr) && validate_acme_csr(order, csr)
268
+ content_type 'application/problem+json'
269
+ halt 400, {
270
+ type: Bullion::Acme::Errors::BadCSR.new.acme_error,
271
+ detail: 'CSR failed validation'
272
+ }.to_json
273
+ end
274
+
275
+ cert_id = sign_csr(csr, @user.contacts.first, acme: true).last
276
+
277
+ order.certificate_id = cert_id
278
+ order.status = 'valid'
279
+ order.save
280
+
281
+ data = {
282
+ status: order.status,
283
+ expires: order.expires,
284
+ notBefore: order.not_before,
285
+ notAfter: order.not_after,
286
+ identifiers: order.identifiers,
287
+ authorizations: order.authorizations.map { |a| uri("/authorizations/#{a.id}") },
288
+ finalize: uri("/orders/#{order.id}/finalize")
289
+ }
290
+
291
+ data[:certificate] = uri("/certificates/#{order.certificate.id}") if order.status == 'valid'
292
+
293
+ data.to_json
294
+ end
295
+
296
+ # Shows that the client controls the account private key
297
+ # @see https://tools.ietf.org/html/rfc8555#section-7.5
298
+ post '/authorizations/:id' do
299
+ parse_acme_jwt
300
+
301
+ content_type 'application/json'
302
+ add_acme_headers @new_nonce
303
+
304
+ authorization = Models::Authorization.find(params[:id])
305
+ halt 404 unless authorization
306
+
307
+ data = {
308
+ status: authorization.status,
309
+ expires: authorization.expires,
310
+ identifier: authorization.identifier,
311
+ challenges: authorization.challenges.map do |c|
312
+ chash = {}
313
+ chash[:type] = c.acme_type
314
+ chash[:url] = uri("/challenges/#{c.id}")
315
+ chash[:token] = c.token
316
+ chash[:status] = c.status
317
+ chash[:validated] = c.validated if c.status == 'valid'
318
+
319
+ chash
320
+ end
321
+ }
322
+
323
+ data.to_json
324
+ end
325
+
326
+ # Starts server verification of a challenge (either HTTP call or DNS lookup)
327
+ # @see https://tools.ietf.org/html/rfc8555#section-7.5.1
328
+ post '/challenges/:id' do
329
+ parse_acme_jwt
330
+
331
+ content_type 'application/json'
332
+ add_acme_headers @new_nonce
333
+
334
+ challenge = Models::Challenge.find(params[:id])
335
+
336
+ # Oddly enough, cert-manager uses a GET request for retrieving Challenge info
337
+ challenge.client.attempt unless @parsed_body[:payload] == ''
338
+
339
+ data = {
340
+ type: challenge.acme_type,
341
+ status: challenge.status,
342
+ expires: challenge.expires,
343
+ token: challenge.token,
344
+ url: uri("/challenges/#{challenge.id}")
345
+ }
346
+
347
+ data[:validated] = challenge.validated if challenge.status == 'valid'
348
+
349
+ data.to_json
350
+ end
351
+
352
+ # Retrieves a signed certificate
353
+ # @see https://tools.ietf.org/html/rfc8555#section-7.4.2
354
+ post '/certificates/:id' do
355
+ parse_acme_jwt
356
+
357
+ order = Models::Order.where(certificate_id: params[:id]).first
358
+ if order && order.status == 'valid'
359
+ content_type 'application/pem-certificate-chain'
360
+
361
+ cert = Models::Certificate.find(params[:id])
362
+
363
+ cert.data + Bullion.ca_cert.to_pem
364
+ else
365
+ halt(422, { 'error': 'Order not valid' }.to_json)
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end