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