bullion 0.1.2 → 0.3.0

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