bullion 0.1.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/config.ru CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  # \ -s puma
4
4
 
5
- require 'bullion'
5
+ require "bullion"
6
6
  Bullion.validate_config!
7
7
 
8
- require 'prometheus/middleware/collector'
9
- require 'prometheus/middleware/exporter'
8
+ require "prometheus/middleware/collector"
9
+ require "prometheus/middleware/exporter"
10
10
 
11
11
  use Rack::ShowExceptions
12
12
  use Rack::Deflater
@@ -15,8 +15,8 @@ use Prometheus::Middleware::Exporter
15
15
 
16
16
  # Prometheus metrics are on /metrics
17
17
  mappings = {
18
- '/ping' => Bullion::Services::Ping.new,
19
- '/acme' => Bullion::Services::CA.new
18
+ "/ping" => Bullion::Services::Ping.new,
19
+ "/acme" => Bullion::Services::CA.new
20
20
  }
21
21
 
22
22
  run Rack::URLMap.new(mappings)
@@ -10,7 +10,7 @@ class CreateCertificates < ActiveRecord::Migration[6.1]
10
10
  t.text :alternate_names
11
11
  t.string :requester
12
12
  t.boolean :validated, null: false, default: false, index: true
13
- t.integer :serial, null: false, index: { unique: true }
13
+ t.bigint :serial, null: false, index: { unique: true }
14
14
 
15
15
  t.timestamps
16
16
  end
@@ -4,7 +4,7 @@
4
4
  class CreateOrders < ActiveRecord::Migration[6.1]
5
5
  def change
6
6
  create_table :orders do |t|
7
- t.string :status, null: false, default: 'pending', index: true
7
+ t.string :status, null: false, default: "pending", index: true
8
8
  t.timestamp :expires, null: false, index: true
9
9
  t.text :identifiers, null: false
10
10
  t.timestamp :not_before, null: false
@@ -4,7 +4,7 @@
4
4
  class CreateAuthorizations < ActiveRecord::Migration[6.1]
5
5
  def change
6
6
  create_table :authorizations do |t|
7
- t.string :status, null: false, default: 'pending', index: true
7
+ t.string :status, null: false, default: "pending", index: true
8
8
  t.timestamp :expires, null: false, index: true
9
9
  t.text :identifier, null: false
10
10
 
data/db/schema.rb CHANGED
@@ -10,25 +10,24 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema.define(version: 2021_01_06_060335) do
14
-
13
+ ActiveRecord::Schema[7.0].define(version: 2021_01_06_060335) do
15
14
  create_table "accounts", force: :cascade do |t|
16
15
  t.boolean "tos_agreed", default: true, null: false
17
16
  t.text "public_key", null: false
18
17
  t.text "contacts", null: false
19
- t.datetime "created_at", precision: 6, null: false
20
- t.datetime "updated_at", precision: 6, null: false
18
+ t.datetime "created_at", null: false
19
+ t.datetime "updated_at", null: false
21
20
  t.index ["public_key"], name: "index_accounts_on_public_key", unique: true
22
21
  t.index ["tos_agreed"], name: "index_accounts_on_tos_agreed"
23
22
  end
24
23
 
25
24
  create_table "authorizations", force: :cascade do |t|
26
25
  t.string "status", default: "pending", null: false
27
- t.datetime "expires", null: false
26
+ t.datetime "expires", precision: nil, null: false
28
27
  t.text "identifier", null: false
29
28
  t.integer "order_id"
30
- t.datetime "created_at", precision: 6, null: false
31
- t.datetime "updated_at", precision: 6, null: false
29
+ t.datetime "created_at", null: false
30
+ t.datetime "updated_at", null: false
32
31
  t.index ["expires"], name: "index_authorizations_on_expires"
33
32
  t.index ["order_id"], name: "index_authorizations_on_order_id"
34
33
  t.index ["status"], name: "index_authorizations_on_status"
@@ -41,9 +40,9 @@ ActiveRecord::Schema.define(version: 2021_01_06_060335) do
41
40
  t.text "alternate_names"
42
41
  t.string "requester"
43
42
  t.boolean "validated", default: false, null: false
44
- t.integer "serial", null: false
45
- t.datetime "created_at", precision: 6, null: false
46
- t.datetime "updated_at", precision: 6, null: false
43
+ t.bigint "serial", null: false
44
+ t.datetime "created_at", null: false
45
+ t.datetime "updated_at", null: false
47
46
  t.index ["csr_fingerprint"], name: "index_certificates_on_csr_fingerprint"
48
47
  t.index ["serial"], name: "index_certificates_on_serial", unique: true
49
48
  t.index ["subject"], name: "index_certificates_on_subject"
@@ -53,12 +52,12 @@ ActiveRecord::Schema.define(version: 2021_01_06_060335) do
53
52
  create_table "challenges", force: :cascade do |t|
54
53
  t.string "acme_type", null: false
55
54
  t.string "status", default: "pending", null: false
56
- t.datetime "expires", null: false
55
+ t.datetime "expires", precision: nil, null: false
57
56
  t.string "token", null: false
58
- t.datetime "validated"
57
+ t.datetime "validated", precision: nil
59
58
  t.integer "authorization_id"
60
- t.datetime "created_at", precision: 6, null: false
61
- t.datetime "updated_at", precision: 6, null: false
59
+ t.datetime "created_at", null: false
60
+ t.datetime "updated_at", null: false
62
61
  t.index ["acme_type"], name: "index_challenges_on_acme_type"
63
62
  t.index ["authorization_id"], name: "index_challenges_on_authorization_id"
64
63
  t.index ["expires"], name: "index_challenges_on_expires"
@@ -67,21 +66,21 @@ ActiveRecord::Schema.define(version: 2021_01_06_060335) do
67
66
 
68
67
  create_table "nonces", force: :cascade do |t|
69
68
  t.string "token", null: false
70
- t.datetime "created_at", precision: 6, null: false
71
- t.datetime "updated_at", precision: 6, null: false
69
+ t.datetime "created_at", null: false
70
+ t.datetime "updated_at", null: false
72
71
  t.index ["token"], name: "index_nonces_on_token", unique: true
73
72
  end
74
73
 
75
74
  create_table "orders", force: :cascade do |t|
76
75
  t.string "status", default: "pending", null: false
77
- t.datetime "expires", null: false
76
+ t.datetime "expires", precision: nil, null: false
78
77
  t.text "identifiers", null: false
79
- t.datetime "not_before", null: false
80
- t.datetime "not_after", null: false
78
+ t.datetime "not_before", precision: nil, null: false
79
+ t.datetime "not_after", precision: nil, null: false
81
80
  t.integer "certificate_id"
82
81
  t.integer "account_id"
83
- t.datetime "created_at", precision: 6, null: false
84
- t.datetime "updated_at", precision: 6, null: false
82
+ t.datetime "created_at", null: false
83
+ t.datetime "updated_at", null: false
85
84
  t.index ["account_id"], name: "index_orders_on_account_id"
86
85
  t.index ["certificate_id"], name: "index_orders_on_certificate_id"
87
86
  t.index ["expires"], name: "index_orders_on_expires"
@@ -6,11 +6,11 @@ module Bullion
6
6
  class Error < Bullion::Error
7
7
  # @see https://tools.ietf.org/html/rfc8555#section-6.7
8
8
  def acme_type
9
- 'genericError'
9
+ "genericError"
10
10
  end
11
11
 
12
12
  def acme_preface
13
- 'urn:ietf:params:acme:error:'
13
+ "urn:ietf:params:acme:error:"
14
14
  end
15
15
 
16
16
  def acme_error
@@ -22,49 +22,49 @@ module Bullion
22
22
  # ACME exception for bad CSRs
23
23
  class BadCsr < Bullion::Acme::Error
24
24
  def acme_type
25
- 'badCSR'
25
+ "badCSR"
26
26
  end
27
27
  end
28
28
 
29
29
  # ACME exception for bad Nonces
30
30
  class BadNonce < Bullion::Acme::Error
31
31
  def acme_type
32
- 'badNonce'
32
+ "badNonce"
33
33
  end
34
34
  end
35
35
 
36
36
  # ACME exception for invalid contacts in accounts
37
37
  class InvalidContact < Bullion::Acme::Error
38
38
  def acme_type
39
- 'invalidContact'
39
+ "invalidContact"
40
40
  end
41
41
  end
42
42
 
43
43
  # ACME exception for invalid orders
44
44
  class InvalidOrder < Bullion::Acme::Error
45
45
  def acme_type
46
- 'invalidOrder'
46
+ "invalidOrder"
47
47
  end
48
48
  end
49
49
 
50
50
  # ACME exception for malformed requests
51
51
  class Malformed < Bullion::Acme::Error
52
52
  def acme_type
53
- 'malformed'
53
+ "malformed"
54
54
  end
55
55
  end
56
56
 
57
57
  # ACME exception for unsupported contacts in accounts
58
58
  class UnsupportedContact < Bullion::Acme::Error
59
59
  def acme_type
60
- 'unsupportedContact'
60
+ "unsupportedContact"
61
61
  end
62
62
  end
63
63
 
64
64
  # Non-standard exception for unsupported challenge types
65
65
  class UnsupportedChallengeType < Bullion::Acme::Error
66
66
  def acme_error
67
- 'urn:ietf:params:bullion:error:unsupportedChallengeType'
67
+ "urn:ietf:params:bullion:error:unsupportedChallengeType"
68
68
  end
69
69
  end
70
70
  end
@@ -5,7 +5,7 @@ module Bullion
5
5
  class ChallengeClient
6
6
  ChallengeClientMetric = Prometheus::Client::Histogram.new(
7
7
  :challenge_execution_seconds,
8
- docstring: 'Challenge execution histogram in seconds',
8
+ docstring: "Challenge execution histogram in seconds",
9
9
  labels: %i[acme_type status]
10
10
  )
11
11
  MetricsRegistry.register(ChallengeClientMetric)
@@ -28,7 +28,7 @@ module Bullion
28
28
  success = perform
29
29
  if success
30
30
  LOGGER.info "Validated #{type} #{identifier}"
31
- challenge.status = 'valid'
31
+ challenge.status = "valid"
32
32
  challenge.validated = Time.now
33
33
  else
34
34
  sleep rand(2..4)
@@ -38,7 +38,7 @@ module Bullion
38
38
 
39
39
  unless success
40
40
  LOGGER.info "Failed to validate #{type} #{identifier}"
41
- challenge.status = 'invalid'
41
+ challenge.status = "invalid"
42
42
  end
43
43
 
44
44
  challenge.save
@@ -53,7 +53,7 @@ module Bullion
53
53
  # rubocop:enable Metrics/MethodLength
54
54
 
55
55
  def identifier
56
- challenge.authorization.identifier['value']
56
+ challenge.authorization.identifier["value"]
57
57
  end
58
58
  end
59
59
  end
@@ -6,42 +6,57 @@ module Bullion
6
6
  # @see https://tools.ietf.org/html/rfc8555#section-8.4
7
7
  class DNS < ChallengeClient
8
8
  def type
9
- 'DNS01'
9
+ "DNS01"
10
10
  end
11
11
 
12
12
  def perform
13
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/, '')
14
+ expected_value = digest_value("#{challenge.token}.#{challenge.thumbprint}")
19
15
 
20
16
  value == expected_value
21
17
  end
22
18
 
19
+ def digest_value(string)
20
+ digester = OpenSSL::Digest.new("SHA256")
21
+ digest = digester.digest(string)
22
+ # clean up the digest output so it can match the provided challenge value
23
+ Base64.urlsafe_encode64(digest).sub(/[\s=]*\z/, "")
24
+ end
25
+
23
26
  def dns_value
24
27
  name = "_acme-challenge.#{identifier}"
25
28
 
26
29
  # Randomly select a nameserver to pull the TXT record
27
30
  nameserver = NAMESERVERS.sample
28
31
 
29
- begin
30
- records = Resolv::DNS.open(nameserver: nameserver) do |dns|
31
- dns.getresources(
32
- name,
33
- Resolv::DNS::Resource::IN::TXT
34
- )
32
+ LOGGER.debug "Looking up #{name}"
33
+ records = records_for(name, nameserver)
34
+ raise "Failed to find records for #{name}" unless records
35
+
36
+ record = records.map(&:strings).flatten.first
37
+ LOGGER.debug "Resolved #{name} to value #{record}"
38
+ record
39
+ rescue Resolv::ResolvError
40
+ msg = ["Resolution error for #{name}"]
41
+ msg << "via #{nameserver}" if nameserver
42
+ LOGGER.info msg.join(" ")
43
+ false
44
+ rescue StandardError => e
45
+ msg = ["Error '#{e.message}' for #{name}"]
46
+ msg << "with #{nameserver}" if nameserver
47
+ LOGGER.warn msg.join(" ")
48
+ false
49
+ end
50
+
51
+ def records_for(name, nameserver = nil)
52
+ if nameserver
53
+ Resolv::DNS.open(nameserver:) do |dns|
54
+ dns.getresources(name, Resolv::DNS::Resource::IN::TXT)
55
+ end
56
+ else
57
+ Resolv::DNS.open do |dns|
58
+ dns.getresources(name, Resolv::DNS::Resource::IN::TXT)
35
59
  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
60
  end
46
61
  end
47
62
  end
@@ -6,28 +6,32 @@ module Bullion
6
6
  # @see https://tools.ietf.org/html/rfc8555#section-8.3
7
7
  class HTTP < ChallengeClient
8
8
  def type
9
- 'HTTP01'
9
+ "HTTP01"
10
10
  end
11
11
 
12
12
  def perform
13
13
  response = begin
14
- HTTParty.get(
15
- challenge_url,
16
- verify: false,
17
- headers: { 'User-Agent' => "Bullion/#{Bullion::VERSION}" }
18
- ).body
14
+ retrieve_body(challenge_url)
19
15
  rescue SocketError
20
16
  LOGGER.debug "Failed to connect to #{challenge_url}"
21
- ''
17
+ ""
22
18
  end
23
19
 
24
- token, thumbprint = response.split('.')
20
+ token, thumbprint = response.split(".")
25
21
  token == challenge.token && thumbprint == challenge.thumbprint
26
22
  end
27
23
 
28
24
  def challenge_url
29
25
  "http://#{identifier}/.well-known/acme-challenge/#{challenge.token}"
30
26
  end
27
+
28
+ def retrieve_body(url)
29
+ HTTParty.get(
30
+ url,
31
+ verify: false,
32
+ headers: { "User-Agent" => "Bullion/#{Bullion::VERSION}" }
33
+ ).body
34
+ end
31
35
  end
32
36
  end
33
37
  end
@@ -17,7 +17,7 @@ module Bullion
17
17
 
18
18
  # check nonce
19
19
  if validate_nonce
20
- nonce = Models::Nonce.where(token: @header_data['nonce']).first
20
+ nonce = Models::Nonce.where(token: @header_data["nonce"]).first
21
21
  raise Bullion::Acme::Errors::BadNonce unless nonce
22
22
 
23
23
  nonce.destroy
@@ -27,7 +27,7 @@ module Bullion
27
27
  @json_body[:protected],
28
28
  @json_body[:payload],
29
29
  @json_body[:signature]
30
- ].join('.')
30
+ ].join(".")
31
31
 
32
32
  # Either use the provided key or find the current user's public key
33
33
  public_key = key || user_public_key
@@ -36,14 +36,14 @@ module Bullion
36
36
  compat_public_key = openssl_compat(public_key)
37
37
 
38
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'])
39
+ if @payload_data && @payload_data != ""
40
+ JWT.decode(jwt_data, compat_public_key, true, { algorithm: @header_data["alg"] })
41
41
  else
42
- digest = digest_from_alg(@header_data['alg'])
42
+ digest = digest_from_alg(@header_data["alg"])
43
43
 
44
- sig = if @header_data['alg'].downcase.start_with?('es')
44
+ sig = if @header_data["alg"].downcase.start_with?("es")
45
45
  ecdsa_sig_to_der(signature)
46
- elsif @header_data['alg'].downcase.start_with?('rs')
46
+ elsif @header_data["alg"].downcase.start_with?("rs")
47
47
  Base64.urlsafe_decode64(signature)
48
48
  end
49
49
 
@@ -65,7 +65,7 @@ module Bullion
65
65
  end
66
66
 
67
67
  def extract_payload_data
68
- if @json_body[:payload] && @json_body[:payload] != ''
68
+ if @json_body[:payload] && @json_body[:payload] != ""
69
69
  JSON.parse(Base64.decode64(@json_body[:payload]))
70
70
  else
71
71
  @json_body[:payload]
@@ -73,52 +73,39 @@ module Bullion
73
73
  end
74
74
 
75
75
  def user_public_key
76
- @user = if @header_data['kid']
77
- user_id = @header_data['kid'].split('/').last
76
+ @user = if @header_data["kid"]
77
+ user_id = @header_data["kid"].split("/").last
78
78
  return unless user_id
79
79
 
80
80
  Models::Account.find(user_id)
81
81
  else
82
- Models::Account.where(public_key: @header_data['jwk']).last
82
+ Models::Account.where(public_key: @header_data["jwk"]).last
83
83
  end
84
84
 
85
85
  @user.public_key
86
86
  end
87
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
88
  # Validation helpers
102
89
 
103
90
  def validate_account_data(hash)
104
- unless [true, false, nil].include?(hash['onlyReturnExisting'])
91
+ unless [true, false, nil].include?(hash["onlyReturnExisting"])
105
92
  raise Bullion::Acme::Errors::Malformed,
106
- "Invalid onlyReturnExisting: #{hash['onlyReturnExisting']}"
93
+ "Invalid onlyReturnExisting: #{hash["onlyReturnExisting"]}"
107
94
  end
108
95
 
109
- unless hash['contact'].is_a?(Array)
96
+ unless hash["contact"].is_a?(Array)
110
97
  raise Bullion::Acme::Errors::InvalidContact,
111
- "Invalid contacts format: #{hash['contact'].class}, #{hash}"
98
+ "Invalid contacts format: #{hash["contact"].class}, #{hash}"
112
99
  end
113
100
 
114
- unless hash['contact'].size.positive?
101
+ unless hash["contact"].size.positive?
115
102
  raise Bullion::Acme::Errors::InvalidContact,
116
- 'Empty contacts list'
103
+ "Empty contacts list"
117
104
  end
118
105
 
119
106
  # Contacts must be a valid email
120
107
  # TODO: find a better email verification approach
121
- unless hash['contact'].reject { |c| c.match?(/^mailto:[a-zA-Z0-9@.+-]{3,}/) }.empty?
108
+ unless hash["contact"].grep_v(/^mailto:[a-zA-Z0-9@.+-]{3,}/).empty?
122
109
  raise Bullion::Acme::Errors::UnsupportedContact
123
110
  end
124
111
 
@@ -131,31 +118,34 @@ module Bullion
131
118
  csr_domains = extract_csr_domains(csr_sans)
132
119
  csr_cn = cn_from_csr(csr)
133
120
 
134
- order_domains = order.identifiers.map { |i| i['value'] }
121
+ order_domains = order.identifiers.map { |i| i["value"] }
122
+
123
+ # Make sure the CSR has a valid public key
124
+ raise Bullion::Acme::Errors::BadCsr unless csr.verify(csr.public_key)
135
125
 
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
126
+ return false unless order.status == "ready"
127
+ raise Bullion::Acme::Errors::BadCsr unless csr_domains.include?(csr_cn)
128
+ raise Bullion::Acme::Errors::BadCsr unless csr_domains.sort == order_domains.sort
139
129
 
140
130
  true
141
131
  end
142
132
 
143
133
  def validate_order(hash)
144
- validate_order_nb_and_na(hash['notBefore'], hash['notAfter'])
134
+ validate_order_nb_and_na(hash["notBefore"], hash["notAfter"])
145
135
 
146
136
  # Don't approve empty orders
147
- raise Bullion::Acme::Errors::InvalidOrder, 'Empty order!' if hash['identifiers'].empty?
137
+ raise Bullion::Acme::Errors::InvalidOrder, "Empty order!" if hash["identifiers"].empty?
148
138
 
149
- order_domains = hash['identifiers'].select { |ident| ident['type'] == 'dns' }
139
+ order_domains = hash["identifiers"].select { |ident| ident["type"] == "dns" }
150
140
 
151
141
  # Don't approve an order with identifiers that _aren't_ of type 'dns'
152
- unless hash['identifiers'] == order_domains
142
+ unless hash["identifiers"] == order_domains
153
143
  raise Bullion::Acme::Errors::InvalidOrder, 'Only type "dns" allowed'
154
144
  end
155
145
 
156
146
  # Extract domains that end with something in our allowed domains list
157
147
  valid_domains = order_domains.reject do |domain|
158
- endings = CA_DOMAINS.select { |d| domain['value'].end_with?(d) }
148
+ endings = CA_DOMAINS.select { |d| domain["value"].end_with?(d) }
159
149
  endings.empty?
160
150
  end
161
151
 
@@ -5,8 +5,8 @@ module Bullion
5
5
  # Sinatra service helper methods
6
6
  module Service
7
7
  def add_acme_headers(nonce, additional: {})
8
- headers['Replay-Nonce'] = nonce
9
- headers['Link'] = "<#{uri('/directory')}>;rel=\"index\""
8
+ headers["Replay-Nonce"] = nonce
9
+ headers["Link"] = "<#{uri("/directory")}>;rel=\"index\""
10
10
 
11
11
  additional.each do |name, value|
12
12
  headers[name.to_s] = value.to_s