bullion 0.12.0 → 0.12.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff6022efee880f75147cc1a4ce9a25dd9525075002c20f3c2be0c917a550d167
4
- data.tar.gz: 114a84d226ef0e2c613be0b6e80f33fe73b379ee20016291baca59662beac094
3
+ metadata.gz: 9cef665959a8cadbb7227013c2680c914772f9f61a33c230fd405f0d9f0a414a
4
+ data.tar.gz: 106020f0549902f1f3966270a4ba9cfc135935ccc9fe1969dd6671a9dcb91738
5
5
  SHA512:
6
- metadata.gz: a82cec86d1143c61349ad216337646ef80e12c0a6d1b03af48bce0b0608db08b0edabe8d012b8375c9603a6a721c09d190551994549ac269b56c13406333cadd
7
- data.tar.gz: 423cc0b9ea94aa2c28076d860bd58502af1383065e6eecf3db1e19b5d00b3a7f8756f0c09b7c839c3b36128563264f58a2e4176ada4051127f73bd58f3757db7
6
+ metadata.gz: ec17f3d4a94faf7215513fcdb871f0f6fbc92fc2cf149f41b373bcd125a87889cd014d00fee9534fe75aa4f82aa50745fd82eebbd6a7b0f6cacabeb454ec32a3
7
+ data.tar.gz: d7366593b009bfe773fcdb7eb8ce20b862ee08c5633081249f3c5ad8d559a5dec3809440983eea6fd98e3ec7645eacf12d3380cc5784c5d80e81ed1263974075
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.1](https://github.com/jgnagy/bullion/compare/bullion/v0.12.0...bullion/v0.12.1) (2026-11-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * accept the full RFC 8555 revocation set, add problem docs (ghostex-d76.1) ([e68c9a1](https://github.com/jgnagy/bullion/commit/e68c9a12f17e0655b2a8d9587fd78550df9cc7ab))
9
+ * bound ACME nonce minting and expire stale nonces (ghostex-d76.3) ([3938285](https://github.com/jgnagy/bullion/commit/3938285d6dc9b6c91cbd6d9aff5a9729a3cf0760))
10
+ * bump trilogy to 2.13.2 for GHSA-744p-w4gc-m3gh (ghostex-d76.7) ([4b3c657](https://github.com/jgnagy/bullion/commit/4b3c657c9eda1a4348f6f1d6aeeb4327269c80ba))
11
+ * emit full-width EC JWK coordinates; 400 on malformed EC points (ghostex-d76.4) ([2e98fed](https://github.com/jgnagy/bullion/commit/2e98fed5a227a77548426d797a444c207f0ae58c))
12
+ * emit full-width EC JWK coordinates; return 400 malformed on invalid EC points (ghostex-d76.4) ([b23dfd1](https://github.com/jgnagy/bullion/commit/b23dfd1a7219ed6fc7137369ed4370ad57d42223))
13
+ * enforce account ownership across ACME by-ID resources (ghostex-d76.1) ([0dfd9b2](https://github.com/jgnagy/bullion/commit/0dfd9b2f74567a051b55c767f6681c4b441eba57))
14
+ * enforce authorization and order expiration (ghostex-d76.6) ([c909fc9](https://github.com/jgnagy/bullion/commit/c909fc96d5f97856bd1e2117dcd7bd1b993eee9a))
15
+ * fail closed when CA_SECRET is unset outside development/test (ghostex-d76.2) ([f203327](https://github.com/jgnagy/bullion/commit/f2033274f83f7e2de43c20accea0259694cf39f1))
16
+ * mint 64-bit certificate serials (ghostex-d76.3) ([db594c6](https://github.com/jgnagy/bullion/commit/db594c6075b42e6521f827094a9c28ad4015c44d))
17
+ * sanitize unhandled errors for ACME clients (ghostex-d76.3) ([c382457](https://github.com/jgnagy/bullion/commit/c3824577186dcbc7f9e4eb0572571659df288ecd))
18
+
3
19
  ## [0.12.0](https://github.com/jgnagy/bullion/compare/bullion/v0.11.6...bullion/v0.12.0) (2026-10-24)
4
20
 
5
21
 
data/README.md CHANGED
@@ -24,7 +24,7 @@ The goal of the Bullion project is to make running a scalable, internal ACMEv2 C
24
24
 
25
25
  Before running Bullion, you need a CA key pair. Bullion requires two PEM files:
26
26
 
27
- - **`tls.key`** — An encrypted private key (RSA, ECDSA, or EdDSA) for signing certificates. Bullion supports encrypted PEM keys using AES-128-CBC.
27
+ - **`tls.key`** — An encrypted private key (RSA, ECDSA, or EdDSA) for signing certificates. Bullion supports encrypted PEM keys using AES-128-CBC. The key must be encrypted with the passphrase given in `CA_SECRET` (see [CA Secret](#ca-secret)).
28
28
  - **`tls.crt`** — The corresponding public certificate. If Bullion is an intermediate CA, include the root CA's certificate in this file as well (certificate chain order: intermediate first, then root).
29
29
 
30
30
  You can generate these with OpenSSL. For example, a self-signed root CA:
@@ -155,12 +155,12 @@ Whether run locally or via Docker, the following environment variables configure
155
155
  | Environment Variable | Default Value | Description |
156
156
  | --- | --- | --- |
157
157
  | `CA_DIR` | `./tmp/` | Path to directory container the public and private key for Bullion. |
158
- | `CA_SECRET` | `SomeS3cret` | Secret used to read the encrypted `$CA_KEY` PEM. |
158
+ | `CA_SECRET` | _None_ | **(Required outside development/test)** Passphrase used to decrypt the encrypted CA private key PEM. Bullion refuses to boot when it is missing. See [CA Secret](#ca-secret). |
159
159
  | `CA_KEY_PATH` | `$CA_DIR/tls.key` | Private signer key for Bullion. Keep this safe! |
160
160
  | `CA_CERT_PATH` | `$CA_DIR/tls.crt` | Public cert for Bullion. If Bullion is an intermediate CA, you'll want to include the root CA's public cert in this file as well the signed cert for Bullion. |
161
161
  | `CA_DOMAINS` | `example.com` | A comma-delimited list of domains for which Bullion will sign certificate requests. Subdomains are automatically allowed. Certificates containing other domains will be rejected. |
162
162
  | `CERT_VALIDITY_DURATION` | `7776000` | How long should issued certs be valid (in seconds)? Default is 90 days. |
163
- | `DATABASE_URL` | _None_ | **(Required)** A shorthand for telling Bullion how to connect to a database. Acceptable URLs will either begin with `sqlite3:` or [`trilogy://`](https://github.com/trilogy-libraries/trilogy/tree/main/contrib/ruby). ActiveRecord connection options may be passed as query parameters, e.g. `trilogy://user:pass@host:3306/bullion?max_connections=25&checkout_timeout=5`. |
163
+ | `DATABASE_URL` | _None_ | **(Required)** A shorthand for telling Bullion how to connect to a database. Acceptable URLs will either begin with `sqlite3:` or [`trilogy://`](https://github.com/trilogy-libraries/trilogy/tree/main/contrib/ruby). ActiveRecord connection options may be passed as query parameters, e.g. `trilogy://user:pass@host:3306/bullion?max_connections=25&checkout_timeout=5`. MySQL/MariaDB connections default to the `utf8mb4` charset; the legacy `sjis`, `cp932`, `big5`, `gbk` and `gb18030` charsets are not supported by Trilogy 2.13.2 and raise `ArgumentError` if requested. |
164
164
  | `DNS01_NAMESERVERS` | _None_ | A comma-delimited list of nameservers to use for resolving [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenges. Usually you'll want this to be set to your _internal_ nameservers so internal names resolve correctly. When not set, it'll use the host's DNS. |
165
165
  | `LOG_LEVEL` | `warn` | Log level for Bullion. Supported levels (starting with the noisiest) are debug, info, warn, error, and fatal. |
166
166
  | `BULLION_PORT` | `9292` | TCP port Bullion will listen on. |
@@ -171,6 +171,43 @@ Whether run locally or via Docker, the following environment variables configure
171
171
 
172
172
  Because Bullion serves requests through Itsi's [fiber scheduler](https://itsi.fyi/options/fiber_scheduler/), each in-flight request leases its own database connection. ActiveRecord's connection pool defaults to 5 connections, so raise `max_connections` on `DATABASE_URL` if you expect more concurrent requests. `checkout_timeout` (default 5 seconds) controls how long a request waits for a connection before failing.
173
173
 
174
+ #### CA Secret
175
+
176
+ `CA_SECRET` is the passphrase that decrypts the CA private key (`tls.key`). Bullion
177
+ **refuses to boot** (raising `Bullion::ConfigError`) when `CA_SECRET` is missing or
178
+ blank and `RACK_ENV` is anything other than `development` or `test` — there is no
179
+ default passphrase in production.
180
+
181
+ Generate a strong passphrase and keep it in your secret manager:
182
+
183
+ ```bash
184
+ openssl rand -base64 48 # e.g. a 64-character random string
185
+ ```
186
+
187
+ The CA private key **must be encrypted with that exact passphrase**. Bullion decrypts
188
+ encrypted PEM keys with OpenSSL, so AES-128-CBC and AES-256-CBC both work:
189
+
190
+ ```bash
191
+ # Generate a new encrypted key
192
+ openssl genrsa -aes256 -out tls.key 4096
193
+
194
+ # Or re-encrypt an existing key with a new passphrase
195
+ openssl pkey -aes256 -in tls.key -out tls.key.new
196
+ ```
197
+
198
+ Then start Bullion with that same passphrase in the environment:
199
+
200
+ ```bash
201
+ export CA_SECRET='<the passphrase you encrypted the key with>'
202
+ itsi
203
+ ```
204
+
205
+ In development and test environments (`RACK_ENV=development` or `RACK_ENV=test`),
206
+ Bullion falls back to a well-known, development-only passphrase so that the `rake`
207
+ demo and test tasks work out of the box; it logs a warning whenever it does so.
208
+ That fallback is never used when `RACK_ENV` is `production` — or when `RACK_ENV` is
209
+ unset, which is treated as a production-like environment.
210
+
174
211
  ### Integrating
175
212
 
176
213
  Any client that speaks the [ACMEv2](https://tools.ietf.org/html/rfc8555) protocol can be pointed at `/acme/directory` and everything else can be auto-discovered.
data/config.ru CHANGED
@@ -6,7 +6,11 @@ Bullion.validate_config!
6
6
  require "prometheus/middleware/collector"
7
7
  require "prometheus/middleware/exporter"
8
8
 
9
- use Rack::ShowExceptions
9
+ # Rack::ShowExceptions renders exception messages and backtraces; keep it out
10
+ # of production so a request that escapes Sinatra's handlers cannot leak them.
11
+ use Rack::ShowExceptions if Bullion::DEV_OR_TEST_ENVIRONMENTS.include?(
12
+ ENV.fetch("RACK_ENV", "development")
13
+ )
10
14
  use Rack::Deflater
11
15
  use Prometheus::Middleware::Collector
12
16
  use Prometheus::Middleware::Exporter
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Index nonce expiry/pruning queries by creation time.
4
+ class AddCreatedAtIndexToNonces < ActiveRecord::Migration[8.1]
5
+ def change
6
+ add_index :nonces, :created_at
7
+ end
8
+ end
data/db/schema.rb CHANGED
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[8.1].define(version: 2026_07_16_000000) do
13
+ ActiveRecord::Schema[8.1].define(version: 2026_11_02_000000) do
14
14
  create_table "accounts", force: :cascade do |t|
15
15
  t.text "contacts", null: false
16
16
  t.datetime "created_at", null: false
@@ -72,6 +72,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_16_000000) do
72
72
  t.datetime "created_at", null: false
73
73
  t.string "token", null: false
74
74
  t.datetime "updated_at", null: false
75
+ t.index ["created_at"], name: "index_nonces_on_created_at"
75
76
  t.index ["token"], name: "index_nonces_on_token", unique: true
76
77
  end
77
78
 
@@ -74,6 +74,16 @@ module Bullion
74
74
  def acme_type = "unsupportedContact"
75
75
  end
76
76
 
77
+ # ACME exception for requests the account is not authorized to make.
78
+ # Also used for not-found-style refusals: the ACME registry has no
79
+ # generic "notFound" problem type, and "unauthorized" is its closest
80
+ # entry ("a request for an identifier the account is not authorized
81
+ # to manage"); the identical response for missing and foreign-owned
82
+ # resources keeps the two cases indistinguishable.
83
+ class Unauthorized < Bullion::Acme::Error
84
+ def acme_type = "unauthorized"
85
+ end
86
+
77
87
  # Non-standard exception for unsupported challenge types
78
88
  class UnsupportedChallengeType < Bullion::Acme::Error
79
89
  def acme_error = "urn:ietf:params:bullion:error:unsupportedChallengeType"
@@ -4,6 +4,9 @@ module Bullion
4
4
  # Common helper functions
5
5
  module Helpers
6
6
  # ACME-specific helper functions
7
+ # The ownership and revocation enforcement work grew this module past
8
+ # the default limit; the helpers are one cohesive ACME concern
9
+ # rubocop:disable-next Metrics/ModuleLength
7
10
  module Acme
8
11
  # Parses and verifies the incoming ACME JWT for authentication
9
12
  # @see https://tools.ietf.org/html/rfc8555#section-6.2
@@ -114,6 +117,130 @@ module Bullion
114
117
  true
115
118
  end
116
119
 
120
+ # Ownership enforcement
121
+ #
122
+ # ACME resources are private to the account that created them. Requests
123
+ # are authenticated via the JWS "kid" (see #parse_acme_jwt), and every
124
+ # by-ID lookup below is scoped to that account: a resource that is
125
+ # missing and a resource owned by someone else are both answered with a
126
+ # 404, so an attacker cannot even learn that another account's order,
127
+ # authorization, challenge, or certificate exists.
128
+
129
+ # Returns the Order named by params[:id] when the authenticated account
130
+ # owns it; nil when it is missing or belongs to a different account.
131
+ def owned_order
132
+ Models::Order.find_by(id: params[:id], account_id: @user.id)
133
+ end
134
+
135
+ # Returns the Authorization named by params[:id] when the authenticated
136
+ # account owns its order; nil when it is missing or foreign-owned.
137
+ def owned_authorization
138
+ Models::Authorization.joins(:order)
139
+ .find_by(id: params[:id], orders: { account_id: @user.id })
140
+ end
141
+
142
+ # Returns the Challenge named by params[:id] when the authenticated
143
+ # account owns the challenge's order; nil when missing or foreign-owned.
144
+ def owned_challenge
145
+ Models::Challenge.joins(authorization: :order)
146
+ .find_by(id: params[:id], orders: { account_id: @user.id })
147
+ end
148
+
149
+ # The identifiers whose authorizations this order has proven and whose
150
+ # proofs have not yet expired.
151
+ def validated_domains(order)
152
+ order.authorizations.select(&:valid_for_issuance?).map { it.identifier["value"] }.sort
153
+ end
154
+
155
+ # Marks a just-validated challenge's authorization valid, and marks the
156
+ # order ready once every one of its authorizations is valid: a single
157
+ # valid challenge proves control of only one identifier, so a
158
+ # multi-identifier order must not become finalizable early.
159
+ def advance_validation!(challenge)
160
+ authorization = challenge.authorization
161
+
162
+ # An elapsed proof must never be promoted or counted. The stored
163
+ # status may remain "valid" for client rendering, but issuance and
164
+ # order readiness rely on #valid_for_issuance?.
165
+ return if challenge.expired? || authorization.expired?
166
+
167
+ authorization.update!(status: "valid") unless authorization.valid_status?
168
+
169
+ order = authorization.order
170
+ return if order.ready_status?
171
+
172
+ order.update!(status: "ready") if order.authorizations.all?(&:valid_for_issuance?)
173
+ end
174
+
175
+ # The refusal detail for a finalize attempt that cannot proceed,
176
+ # distinguishing an elapsed order or authorization from one that is
177
+ # merely not ready. RFC 8555 has no dedicated expiry error, so all
178
+ # three refusals use orderNotReady.
179
+ def finalize_refusal_detail(order)
180
+ return "Order has expired" if order.expired?
181
+ return "An authorization has expired" if order.authorizations.any?(&:expired?)
182
+
183
+ "Order is not ready to be finalized"
184
+ end
185
+
186
+ # Halts with an RFC 7807 problem document for an ACME error
187
+ def halt_acme_error(status, error_class, detail)
188
+ content_type "application/problem+json"
189
+ halt status, { type: error_class.new.acme_error, detail: }.to_json
190
+ end
191
+
192
+ # Refuses a request in the ACME not-found style (RFC 8555 section
193
+ # 6.7): the same problem document answers for resources that do not
194
+ # exist and resources owned by another account, so the existence of
195
+ # someone else's order, authorization, challenge, or certificate is
196
+ # never revealed.
197
+ def halt_acme_not_found
198
+ halt_acme_error(404, Bullion::Acme::Errors::Unauthorized, "Resource not found")
199
+ end
200
+
201
+ # Authenticates a revocation request per RFC 8555 section 7.6: a
202
+ # "kid" in the protected header authenticates the signing account as
203
+ # usual, while a bare "jwk" takes the emergency path — the JWS is
204
+ # verified against the certificate's own public key, so only the
205
+ # holder of the certificate's private key can produce such a request.
206
+ # The header's jwk is a JOSE marker here, not the trusted key: a
207
+ # valid signature under the certificate key proves possession, which
208
+ # is what the spec requires.
209
+ def authenticate_revocation!(cert)
210
+ return parse_acme_jwt if extract_header_data["kid"]
211
+
212
+ begin
213
+ parse_acme_jwt(cert.public_key)
214
+ rescue JWT::DecodeError, OpenSSL::PKey::PKeyError
215
+ halt_acme_not_found
216
+ end
217
+ end
218
+
219
+ # Whether the account may revoke the certificate record under RFC
220
+ # 8555 section 7.6: either it is the account the certificate was
221
+ # issued to, or it holds valid, unexpired authorizations covering
222
+ # every identifier in the certificate. The jwk path needs no account
223
+ # check: its signature already proved possession of the certificate's
224
+ # private key.
225
+ def revocation_authorized?(account, record)
226
+ return true if record.order&.account_id == account.id
227
+
228
+ covered = Models::Authorization.joins(:order)
229
+ .where(orders: { account_id: account.id },
230
+ authorizations: { status: "valid" })
231
+ .select(&:valid_for_issuance?)
232
+ .map { it.identifier["value"] }
233
+
234
+ certificate_domains(record).all? { |domain| covered.include?(domain) }
235
+ end
236
+
237
+ # The identifiers a certificate covers (its subjectAltNames)
238
+ def certificate_domains(record)
239
+ domains = Array(record.alternate_names).map { it.delete_prefix("DNS:") }
240
+ domains = record.order.domains if domains.empty? && record.order
241
+ domains.sort
242
+ end
243
+
117
244
  def acme_csr_valid?(order_csr)
118
245
  csr = order_csr.csr
119
246
  order = order_csr.order
@@ -125,8 +252,13 @@ module Bullion
125
252
  # Make sure the CSR has a valid public key
126
253
  raise Bullion::Acme::Errors::BadCsr unless csr.verify(csr.public_key)
127
254
 
128
- raise Bullion::Acme::Errors::BadCsr if csr_cn && !csr_domains.include?(csr_cn)
129
- raise Bullion::Acme::Errors::BadCsr unless csr_domains.sort == order.domains.sort
255
+ # The CA may only sign identifiers the order has proven control of:
256
+ # the CSR's names must match the validated identifier set exactly, so
257
+ # no extra or substituted name can make it into a certificate.
258
+ validated = validated_domains(order)
259
+
260
+ raise Bullion::Acme::Errors::BadCsr if csr_cn && !validated.include?(csr_cn)
261
+ raise Bullion::Acme::Errors::BadCsr unless csr_domains.sort == validated
130
262
 
131
263
  true
132
264
  end
@@ -188,7 +320,11 @@ module Bullion
188
320
 
189
321
  def extract_valid_order_domains(order_domains)
190
322
  order_domains.reject do |domain|
191
- Bullion.config.ca.domains.none? { domain["value"].end_with?(it) }
323
+ # Only allow a configured domain itself or a proper subdomain of it;
324
+ # the dot boundary stops "nottest.domain" from matching "test.domain"
325
+ Bullion.config.ca.domains.none? do |configured|
326
+ domain["value"] == configured || domain["value"].end_with?(".#{configured}")
327
+ end
192
328
  end
193
329
  end
194
330
 
@@ -7,6 +7,10 @@ module Bullion
7
7
  module Ssl
8
8
  # Converts the incoming key data to an OpenSSL public key usable to verify JWT signatures
9
9
  def openssl_compat(key_data)
10
+ # An OpenSSL key (e.g. a certificate's public key) is already usable;
11
+ # JWK hashes are converted below
12
+ return key_data if key_data.is_a?(OpenSSL::PKey::PKey)
13
+
10
14
  case key_data["kty"]
11
15
  when "RSA"
12
16
  key_data_to_rsa(key_data)
@@ -48,8 +52,7 @@ module Bullion
48
52
  raise "Unknown curve" unless curve_name
49
53
 
50
54
  key_group = OpenSSL::PKey::EC::Group.new(curve_name)
51
- key_bn = OpenSSL::BN.new("\x04#{x}#{y}", 2)
52
- key_point = OpenSSL::PKey::EC::Point.new(key_group, key_bn)
55
+ key_point = ec_point_from_jwk(key_group, x, y)
53
56
 
54
57
  pk_sequence = OpenSSL::ASN1::Sequence.new(
55
58
  [OpenSSL::ASN1::ObjectId("id-ecPublicKey"), OpenSSL::ASN1::ObjectId(curve_name)]
@@ -61,6 +64,18 @@ module Bullion
61
64
  OpenSSL::PKey::EC.new(outer_sequence.to_der)
62
65
  end
63
66
 
67
+ # Builds an EC point from JWK coordinate octets. RFC 7518 requires
68
+ # fixed-width coordinates, and OpenSSL rejects anything else — a
69
+ # malformed or off-curve encoding must surface as an ACME
70
+ # "malformed" request error (RFC 8555 section 6.4), never as an
71
+ # unhandled exception.
72
+ # @see https://tools.ietf.org/html/rfc7518#section-6.2.1
73
+ def ec_point_from_jwk(group, x_octets, y_octets)
74
+ OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04#{x_octets}#{y_octets}", 2))
75
+ rescue OpenSSL::PKey::EC::Point::Error => e
76
+ raise Bullion::Acme::Errors::Malformed, "Invalid EC public key: #{e.message}"
77
+ end
78
+
64
79
  def key_data_to_eddsa(key_data)
65
80
  curve = key_data["crv"]
66
81
  x = base64_to_octet(key_data["x"])
@@ -26,6 +26,22 @@ module Bullion
26
26
  self.expires ||= Time.now + (60 * 60)
27
27
  end
28
28
 
29
+ # Whether this authorization's lifetime has elapsed. The stored status
30
+ # is deliberately left untouched: clients still see the last persisted
31
+ # status, while issuance and revocation coverage consult
32
+ # #valid_for_issuance?.
33
+ def expired?
34
+ expires <= Time.now
35
+ end
36
+
37
+ # Whether this authorization still proves control of its identifier.
38
+ # RFC 8555 carries an "expires" timestamp precisely so a proof stops
39
+ # being honored after its lifetime, even though its stored status has
40
+ # not been rewritten.
41
+ def valid_for_issuance?
42
+ valid_status? && !expired?
43
+ end
44
+
29
45
  def prep_challenges!
30
46
  %w[http-01 dns-01].each do |type|
31
47
  chall = Challenge.new
@@ -13,7 +13,12 @@ module Bullion
13
13
  has_one :order
14
14
 
15
15
  def init_values
16
- self.serial ||= SecureRandom.hex(4).to_i(16)
16
+ # CABF Baseline Requirements require at least 64 bits of CSPRNG output
17
+ # in certificate serial numbers. Eight random bytes provide that, but
18
+ # the serial column is a signed BIGINT, so clear the top bit to keep
19
+ # the value positive and storable. Existing certificates keep their
20
+ # serials; this only affects new issuances.
21
+ self.serial ||= SecureRandom.hex(8).to_i(16) & 0x7FFF_FFFF_FFFF_FFFF
17
22
  end
18
23
 
19
24
  def fingerprint
@@ -29,6 +29,12 @@ module Bullion
29
29
  self.token ||= SecureRandom.alphanumeric(48)
30
30
  end
31
31
 
32
+ # Whether this challenge's lifetime has elapsed. An elapsed challenge
33
+ # must not be promoted to valid or advance its authorization.
34
+ def expired?
35
+ expires <= Time.now
36
+ end
37
+
32
38
  def thumbprint
33
39
  cipher = OpenSSL::Digest.new("SHA256")
34
40
  digest = cipher.digest(lexicographically_ordered_public_key.to_json)
@@ -4,6 +4,19 @@ module Bullion
4
4
  module Models
5
5
  # ACMEv2 Nonce model
6
6
  class Nonce < ActiveRecord::Base
7
+ # How long a minted nonce stays usable. RFC 8555 does not mandate a
8
+ # lifetime; fifteen minutes is comfortably longer than a client needs
9
+ # to use a freshly minted nonce while keeping stale rows short-lived.
10
+ TTL = 15 * 60
11
+
12
+ # Hard ceiling on outstanding nonce rows. A client that mints nonces
13
+ # without consuming them cannot grow the table past this bound.
14
+ MAX_OUTSTANDING = 10_000
15
+
16
+ # How often a mint performs the expiry sweep. Sweeping on every request
17
+ # would scan the table needlessly under normal load.
18
+ CLEANUP_INTERVAL = 60
19
+
7
20
  after_initialize :init_values, unless: :persisted?
8
21
 
9
22
  validates_uniqueness_of :token
@@ -12,11 +25,37 @@ module Bullion
12
25
  self.token ||= SecureRandom.alphanumeric
13
26
  end
14
27
 
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
28
+ # Delete nonces that are older than {TTL}; they can no longer be used.
29
+ def self.clean_up!(now: Time.now)
30
+ where("created_at < ?", now - TTL).delete_all
31
+ end
32
+
33
+ # Keep at most +limit+ nonces, deleting the oldest first. This is the
34
+ # hard bound on table growth: once the cap is reached, each mint evicts
35
+ # the oldest outstanding nonce instead of accumulating indefinitely.
36
+ def self.prune!(limit: MAX_OUTSTANDING)
37
+ excess = count - limit
38
+ return unless excess.positive?
39
+
40
+ ids = order(:created_at).limit(excess).pluck(:id)
41
+ where(id: ids).delete_all
42
+ end
43
+
44
+ # Mint a nonce while keeping the table bounded.
45
+ def self.mint!
46
+ clean_up_if_due!
47
+ prune!
48
+ create.token
49
+ end
50
+
51
+ def self.clean_up_if_due!
52
+ now = Time.now
53
+ return if @last_cleanup_at && now - @last_cleanup_at < CLEANUP_INTERVAL
54
+
55
+ @last_cleanup_at = now
56
+ clean_up!(now:)
19
57
  end
58
+ private_class_method :clean_up_if_due!
20
59
  end
21
60
  end
22
61
  end
@@ -28,6 +28,19 @@ module Bullion
28
28
  self.not_after ||= Time.now + (60 * 60 * 24 * 90) # 90 days
29
29
  end
30
30
 
31
+ # Whether this order's lifetime has elapsed.
32
+ def expired?
33
+ expires <= Time.now
34
+ end
35
+
36
+ # Whether the order may be finalized: it must be ready, within its own
37
+ # lifetime, and every authorization must still prove control. An order
38
+ # already marked ready can outlive its authorizations, so readiness is
39
+ # re-checked here rather than trusted from the persisted status.
40
+ def ready_for_issuance?
41
+ ready_status? && !expired? && authorizations.all?(&:valid_for_issuance?)
42
+ end
43
+
31
44
  def prep_authorizations!
32
45
  identifiers.each do |identifier|
33
46
  authorization = Authorization.new
@@ -13,6 +13,10 @@ module Bullion
13
13
  set :logger, Bullion::LOGGER
14
14
  set :database, Bullion.config.db_url
15
15
  set :show_exceptions, false
16
+ # This service logs unhandled exceptions itself. Sinatra's dump_errors!
17
+ # renders the raw exception message and can raise on invalid bytes before
18
+ # our handler runs, so keep it off in every environment.
19
+ set :dump_errors, false
16
20
  end
17
21
 
18
22
  before do
@@ -22,16 +26,73 @@ module Bullion
22
26
  @json_body = JSON.parse(request.body.read, symbolize_names: true)
23
27
  end
24
28
  rescue StandardError => e
25
- halt(400, { error: "Request must be JSON: #{e.message}}" }.to_json)
29
+ write_service_log(
30
+ "WARN",
31
+ "Rejected malformed request body: #{e.class}: #{safe_exception_message(e)}"
32
+ )
33
+ content_type "application/problem+json"
34
+ halt(400, {
35
+ type: "urn:ietf:params:acme:error:malformed",
36
+ detail: "Request body must be JSON"
37
+ }.to_json)
26
38
  end
27
39
 
28
40
  error do
29
- content_type :json
30
-
31
41
  e = env["sinatra.error"]
32
- resp = { result: "error", message: e.message }
33
- resp[:trace] = e.full_message if settings.environment.to_s != "production"
34
- resp.to_json
42
+ log_unhandled_exception(e)
43
+
44
+ status 500
45
+ content_type "application/problem+json"
46
+ {
47
+ type: "urn:ietf:params:acme:error:serverInternal",
48
+ detail: "Internal server error"
49
+ }.to_json
50
+ end
51
+
52
+ private
53
+
54
+ # Writes the full exception detail (message, backtrace, and causes) to the
55
+ # server log. The text is force-encoded and scrubbed so an exception whose
56
+ # message contains invalid bytes cannot make the logger raise (which would
57
+ # turn the sanitized 500 into an empty response and lose the detail).
58
+ def log_unhandled_exception(exception)
59
+ detail = begin
60
+ exception.full_message(highlight: false)
61
+ rescue StandardError
62
+ "#{exception.class}: #{safe_exception_message(exception)}\n" \
63
+ "#{Array(exception.backtrace).join("\n")}"
64
+ end
65
+
66
+ write_exception_log(
67
+ "Unhandled exception while handling #{request.request_method} #{request.path}:\n#{detail}"
68
+ )
69
+ end
70
+
71
+ # Returns a valid UTF-8 exception message. Interpolating a binary message
72
+ # with invalid bytes into a UTF-8 string can itself raise, so normalize it
73
+ # before it reaches any string concatenation.
74
+ def safe_exception_message(exception)
75
+ exception.message.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
76
+ end
77
+
78
+ def write_exception_log(text)
79
+ write_service_log("ERROR", text)
80
+ end
81
+
82
+ # Prefer Rack's error stream: Itsi wires it to its own log pipeline, while
83
+ # a Logger captured at boot can point at a worker's closed/inherited stream.
84
+ def write_service_log(level, text)
85
+ message = text.dup.force_encoding(Encoding::UTF_8).scrub("?")
86
+ errors = env["rack.errors"]
87
+
88
+ if errors.respond_to?(:puts)
89
+ errors.puts("#{level} bullion: #{message}")
90
+ else
91
+ settings.logger.public_send(level.to_s.downcase.to_sym, message)
92
+ end
93
+ rescue StandardError
94
+ # A logging failure must never replace the sanitized client response.
95
+ nil
35
96
  end
36
97
  end
37
98
  end
@@ -9,8 +9,7 @@ module Bullion
9
9
  helpers Helpers::Ssl
10
10
 
11
11
  before do
12
- Models::Nonce.clean_up! if rand(5) > 2 # randomly clean up
13
- @new_nonce = Models::Nonce.create.token
12
+ @new_nonce = Models::Nonce.mint!
14
13
  end
15
14
 
16
15
  after do
@@ -161,7 +160,7 @@ module Bullion
161
160
  post "/accounts/:id" do
162
161
  parse_acme_jwt
163
162
 
164
- unless params[:id] == @user.id
163
+ unless params[:id].to_i == @user.id
165
164
  content_type "application/json"
166
165
  add_acme_headers @new_nonce
167
166
 
@@ -180,7 +179,7 @@ module Bullion
180
179
  post "/accounts/:id/orders" do
181
180
  parse_acme_jwt
182
181
 
183
- unless params[:id] == @user.id
182
+ unless params[:id].to_i == @user.id
184
183
  content_type "application/json"
185
184
  add_acme_headers @new_nonce
186
185
 
@@ -192,7 +191,7 @@ module Bullion
192
191
 
193
192
  {
194
193
  orders: @user.orders.map { uri("/orders/#{it.id}") }
195
- }
194
+ }.to_json
196
195
  end
197
196
 
198
197
  # Endpoint for creating new orders
@@ -224,11 +223,12 @@ module Bullion
224
223
  post "/orders/:id" do
225
224
  parse_acme_jwt
226
225
 
226
+ order = owned_order
227
+ halt_acme_not_found unless order
228
+
227
229
  content_type "application/json"
228
230
  add_acme_headers @new_nonce
229
231
 
230
- order = Models::Order.find(params[:id])
231
-
232
232
  order_data(order).to_json
233
233
  rescue Bullion::Acme::Error => e
234
234
  content_type "application/problem+json"
@@ -240,21 +240,25 @@ module Bullion
240
240
  post "/orders/:id/finalize" do
241
241
  parse_acme_jwt
242
242
 
243
- order = Models::Order.find(params[:id])
243
+ # Ownership is verified before the CSR is parsed or anything is
244
+ # signed: a foreign CSR must never be signed against another
245
+ # account's validated order.
246
+ order = owned_order
247
+ halt_acme_not_found unless order
244
248
 
245
249
  content_type "application/json"
246
250
  add_acme_headers @new_nonce, additional: { "Location" => uri("/orders/#{order.id}") }
247
251
 
248
252
  order_csr = Models::OrderCsr.from_acme_request(order, @payload_data["csr"])
249
253
 
250
- unless order.ready_status?
254
+ unless order.ready_for_issuance?
251
255
  # Finalize is idempotent for orders that have already been finalized
252
256
  halt 200, order_data(order).to_json if %w[processing valid].include?(order.status)
253
257
 
254
258
  content_type "application/problem+json"
255
259
  halt 403, {
256
260
  type: Bullion::Acme::Errors::OrderNotReady.new.acme_error,
257
- detail: "Order is not ready to be finalized"
261
+ detail: finalize_refusal_detail(order)
258
262
  }.to_json
259
263
  end
260
264
 
@@ -283,12 +287,12 @@ module Bullion
283
287
  post "/authorizations/:id" do
284
288
  parse_acme_jwt
285
289
 
290
+ authorization = owned_authorization
291
+ halt_acme_not_found unless authorization
292
+
286
293
  content_type "application/json"
287
294
  add_acme_headers @new_nonce
288
295
 
289
- authorization = Models::Authorization.find(params[:id])
290
- halt 404 unless authorization
291
-
292
296
  data = {
293
297
  status: authorization.status,
294
298
  expires: authorization.expires,
@@ -316,11 +320,14 @@ module Bullion
316
320
  post "/challenges/:id" do
317
321
  parse_acme_jwt
318
322
 
323
+ # Ownership is verified before triggering validation, so only the
324
+ # account that created an order can cause its challenges to run.
325
+ challenge = owned_challenge
326
+ halt_acme_not_found unless challenge
327
+
319
328
  content_type "application/json"
320
329
  add_acme_headers @new_nonce
321
330
 
322
- challenge = Models::Challenge.find(params[:id])
323
-
324
331
  # Oddly enough, cert-manager uses a GET request for retrieving Challenge info
325
332
  challenge.client.attempt unless @json_body && @json_body[:payload] == ""
326
333
 
@@ -336,10 +343,7 @@ module Bullion
336
343
 
337
344
  if challenge.valid_status?
338
345
  data[:validated] = challenge.validated
339
- authorization = challenge.authorization
340
- authorization.update!(status: "valid") unless authorization.valid_status?
341
- order = authorization.order
342
- order.update!(status: "ready") unless order.ready_status?
346
+ advance_validation!(challenge)
343
347
  end
344
348
 
345
349
  add_link_relation("up", uri("/authorizations/#{challenge.authorization.id}"))
@@ -356,7 +360,9 @@ module Bullion
356
360
  parse_acme_jwt
357
361
  add_acme_headers @new_nonce
358
362
 
359
- order = Models::Order.where(certificate_id: params[:id]).first
363
+ # The order is scoped to the authenticated account: a certificate
364
+ # from another account behaves exactly like a missing one.
365
+ order = Models::Order.find_by(certificate_id: params[:id], account_id: @user.id)
360
366
  if order&.valid_status?
361
367
  content_type "application/pem-certificate-chain"
362
368
 
@@ -371,14 +377,20 @@ module Bullion
371
377
  # Revokes a certificate
372
378
  # @see https://tools.ietf.org/html/rfc8555#section-7.6
373
379
  post "/revokecert" do
374
- parse_acme_jwt
375
- add_acme_headers @new_nonce
376
-
377
380
  # The certificate is base64url-encoded DER
378
- cert_der = Base64.urlsafe_decode64(@payload_data["certificate"])
379
- cert = OpenSSL::X509::Certificate.new(cert_der)
381
+ cert = OpenSSL::X509::Certificate.new(
382
+ Base64.urlsafe_decode64(extract_payload_data["certificate"])
383
+ )
380
384
 
381
- # Find the certificate in the database by matching the PEM data
385
+ # RFC 8555 section 7.6: a revocation is accepted from the account
386
+ # that was issued the certificate, from an account holding valid
387
+ # authorizations for every identifier in the certificate, or when
388
+ # signed with the certificate's own private key (a "jwk" request).
389
+ authenticate_revocation!(cert)
390
+
391
+ add_acme_headers @new_nonce
392
+
393
+ # Find the certificate in the database by matching the serial
382
394
  record = Models::Certificate.find_by(serial: cert.serial.to_i)
383
395
 
384
396
  unless record
@@ -389,6 +401,11 @@ module Bullion
389
401
  }.to_json
390
402
  end
391
403
 
404
+ # kid-authenticated accounts still need a claim on the certificate;
405
+ # a jwk-authenticated request has already proven possession of the
406
+ # certificate's private key
407
+ halt_acme_not_found if @header_data["kid"] && !revocation_authorized?(@user, record)
408
+
392
409
  if record.revoked?
393
410
  content_type "application/problem+json"
394
411
  halt 400, {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bullion
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.1"
5
5
  end
data/lib/bullion.rb CHANGED
@@ -34,9 +34,22 @@ module Bullion
34
34
  LOGGER = Logger.new($stdout)
35
35
  LOGGER.level = ENV.fetch("LOG_LEVEL", :warn)
36
36
 
37
+ # Environments in which Bullion is allowed to fall back to a well-known,
38
+ # development-only passphrase for the CA private key. Every other environment
39
+ # (production, or an unset RACK_ENV) must supply CA_SECRET explicitly.
40
+ DEV_OR_TEST_ENVIRONMENTS = %w[development test].freeze
41
+
42
+ # Development/test-only fallback passphrase for the CA private key. This value is
43
+ # deliberately well-known: it exists so that local development and the test suite
44
+ # can boot without extra setup. It is never used when RACK_ENV is something other
45
+ # than "development" or "test".
46
+ DEV_ONLY_SECRET = "SomeS3cret"
47
+
37
48
  setting :ca, reader: true do
38
49
  setting :dir, default: "tmp", constructor: -> { File.expand_path(it) }
39
- setting :secret, default: "SomeS3cret"
50
+ # The passphrase used to decrypt the CA private key. There is deliberately no
51
+ # default here; see {resolve_ca_secret} and {validate_config!}.
52
+ setting :secret
40
53
  setting(
41
54
  :key_path,
42
55
  default: "tls.key",
@@ -71,11 +84,23 @@ module Bullion
71
84
  MetricsRegistry = Prometheus::Client.registry
72
85
 
73
86
  def self.ca_key
74
- @ca_key ||= begin
75
- OpenSSL::PKey::RSA.new(File.read(config.ca.key_path), config.ca.secret)
87
+ @ca_key ||= load_ca_key
88
+ end
89
+
90
+ # Loads the CA private key, decrypting it with the configured passphrase
91
+ # @raise [ConfigError] if the key cannot be decrypted with the configured passphrase
92
+ # @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC] the decrypted CA private key
93
+ def self.load_ca_key
94
+ pem = File.read(config.ca.key_path)
95
+ begin
96
+ OpenSSL::PKey::RSA.new(pem, config.ca.secret)
76
97
  rescue OpenSSL::PKey::RSAError
77
- OpenSSL::PKey::EC.new(File.read(config.ca.key_path), config.ca.secret)
98
+ OpenSSL::PKey::EC.new(pem, config.ca.secret)
78
99
  end
100
+ rescue OpenSSL::PKey::ECError => e
101
+ raise ConfigError,
102
+ "Unable to decrypt the CA private key at #{config.ca.key_path}: check that CA_SECRET " \
103
+ "matches the passphrase used to encrypt the key (#{e.message})"
79
104
  end
80
105
 
81
106
  def self.ca_cert_file
@@ -94,10 +119,39 @@ module Bullion
94
119
  true
95
120
  end
96
121
 
122
+ # Resolves the passphrase used to decrypt the CA private key.
123
+ #
124
+ # An explicitly configured passphrase always wins. When none is given, a
125
+ # development/test-only fallback is used so that local development and the test
126
+ # suite can boot without extra setup. In every other environment (production, or
127
+ # an unset RACK_ENV) this resolves to +nil+ so that {.validate_config!} fails
128
+ # closed instead of silently using a well-known passphrase.
129
+ #
130
+ # @param secret [String, nil] the configured CA_SECRET value, if any
131
+ # @param rack_env [String, nil] the RACK_ENV value, if any
132
+ # @return [String, nil] the passphrase to use, or +nil+ when one is required
133
+ def self.resolve_ca_secret(secret, rack_env = ENV.fetch("RACK_ENV", nil))
134
+ return secret unless secret.nil? || secret.to_s.strip.empty?
135
+
136
+ DEV_OR_TEST_ENVIRONMENTS.include?(rack_env) ? DEV_ONLY_SECRET : nil
137
+ end
138
+
139
+ # Ensures the passphrase used to decrypt the CA private key is present
140
+ # @raise [ConfigError] if the passphrase is missing, blank, or not a String
141
+ # @return [void]
142
+ def self.validate_ca_secret!
143
+ secret = config.ca.secret
144
+ return if secret.is_a?(String) && !secret.strip.empty?
145
+
146
+ raise ConfigError,
147
+ "Missing or invalid CA_SECRET: set the CA_SECRET environment variable to the " \
148
+ "passphrase that decrypts the CA private key at #{config.ca.key_path}"
149
+ end
150
+
97
151
  # Ensures configuration settings are valid
98
152
  # @see https://support.apple.com/en-us/HT211025
99
153
  def self.validate_config! # rubocop:disable Metrics/AbcSize
100
- raise ConfigError, "Invalid Key Passphrase" unless config.ca.secret.is_a?(String)
154
+ validate_ca_secret!
101
155
 
102
156
  unless File.readable?(config.ca.key_path)
103
157
  raise ConfigError,
@@ -130,8 +184,18 @@ Bullion.configure do |config|
130
184
  db_url = ENV.fetch("DATABASE_URL", nil)
131
185
  nameservers = ENV.fetch("DNS01_NAMESERVERS", nil)
132
186
 
187
+ # The CA passphrase has no default outside of development and test environments;
188
+ # resolve it here so that an unset CA_SECRET fails closed at boot.
189
+ resolved_secret = Bullion.resolve_ca_secret(ca_secret)
190
+ if resolved_secret == Bullion::DEV_ONLY_SECRET && ca_secret.to_s.strip.empty?
191
+ Bullion::LOGGER.warn(
192
+ "CA_SECRET is not set; using the well-known development-only CA passphrase. Set " \
193
+ "CA_SECRET to a strong passphrase for any non-development deployment."
194
+ )
195
+ end
196
+
133
197
  config.ca.dir = ca_dir if ca_dir
134
- config.ca.secret = ca_secret if ca_secret
198
+ config.ca.secret = resolved_secret
135
199
  config.ca.key_path = ca_key_path if ca_key_path
136
200
  config.ca.cert_path = ca_cert_path if ca_cert_path
137
201
  config.ca.domains = ca_domains if ca_domains
@@ -167,7 +231,7 @@ require "bullion/challenge_client"
167
231
  require "bullion/challenge_clients/dns"
168
232
  require "bullion/challenge_clients/http"
169
233
 
170
- if %w[development test].include?(ENV["RACK_ENV"])
234
+ if Bullion::DEV_OR_TEST_ENVIRONMENTS.include?(ENV["RACK_ENV"])
171
235
  require "bullion/rspec/challenge_clients/dns"
172
236
  require "bullion/rspec/challenge_clients/http"
173
237
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bullion
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Gnagy
@@ -69,23 +69,29 @@ dependencies:
69
69
  name: json
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - "~>"
72
+ - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: '2.6'
74
+ version: 2.21.2
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '3.0'
75
78
  type: :runtime
76
79
  prerelease: false
77
80
  version_requirements: !ruby/object:Gem::Requirement
78
81
  requirements:
79
- - - "~>"
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 2.21.2
85
+ - - "<"
80
86
  - !ruby/object:Gem::Version
81
- version: '2.6'
87
+ version: '3.0'
82
88
  - !ruby/object:Gem::Dependency
83
89
  name: jwt
84
90
  requirement: !ruby/object:Gem::Requirement
85
91
  requirements:
86
92
  - - ">="
87
93
  - !ruby/object:Gem::Version
88
- version: '2.7'
94
+ version: 3.2.0
89
95
  - - "<"
90
96
  - !ruby/object:Gem::Version
91
97
  version: '4.0'
@@ -95,7 +101,7 @@ dependencies:
95
101
  requirements:
96
102
  - - ">="
97
103
  - !ruby/object:Gem::Version
98
- version: '2.7'
104
+ version: 3.2.0
99
105
  - - "<"
100
106
  - !ruby/object:Gem::Version
101
107
  version: '4.0'
@@ -103,16 +109,22 @@ dependencies:
103
109
  name: jwt-eddsa
104
110
  requirement: !ruby/object:Gem::Requirement
105
111
  requirements:
106
- - - "~>"
112
+ - - ">="
107
113
  - !ruby/object:Gem::Version
108
114
  version: '0.9'
115
+ - - "<"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.0'
109
118
  type: :runtime
110
119
  prerelease: false
111
120
  version_requirements: !ruby/object:Gem::Requirement
112
121
  requirements:
113
- - - "~>"
122
+ - - ">="
114
123
  - !ruby/object:Gem::Version
115
124
  version: '0.9'
125
+ - - "<"
126
+ - !ruby/object:Gem::Version
127
+ version: '2.0'
116
128
  - !ruby/object:Gem::Dependency
117
129
  name: openssl
118
130
  requirement: !ruby/object:Gem::Requirement
@@ -137,16 +149,22 @@ dependencies:
137
149
  name: prometheus-client
138
150
  requirement: !ruby/object:Gem::Requirement
139
151
  requirements:
140
- - - "~>"
152
+ - - ">="
141
153
  - !ruby/object:Gem::Version
142
154
  version: '4.2'
155
+ - - "<"
156
+ - !ruby/object:Gem::Version
157
+ version: '6.0'
143
158
  type: :runtime
144
159
  prerelease: false
145
160
  version_requirements: !ruby/object:Gem::Requirement
146
161
  requirements:
147
- - - "~>"
162
+ - - ">="
148
163
  - !ruby/object:Gem::Version
149
164
  version: '4.2'
165
+ - - "<"
166
+ - !ruby/object:Gem::Version
167
+ version: '6.0'
150
168
  - !ruby/object:Gem::Dependency
151
169
  name: sinatra
152
170
  requirement: !ruby/object:Gem::Requirement
@@ -221,14 +239,14 @@ dependencies:
221
239
  requirements:
222
240
  - - "~>"
223
241
  - !ruby/object:Gem::Version
224
- version: '2.9'
242
+ version: 2.13.2
225
243
  type: :runtime
226
244
  prerelease: false
227
245
  version_requirements: !ruby/object:Gem::Requirement
228
246
  requirements:
229
247
  - - "~>"
230
248
  - !ruby/object:Gem::Version
231
- version: '2.9'
249
+ version: 2.13.2
232
250
  - !ruby/object:Gem::Dependency
233
251
  name: acme-client
234
252
  requirement: !ruby/object:Gem::Requirement
@@ -373,28 +391,28 @@ dependencies:
373
391
  requirements:
374
392
  - - "~>"
375
393
  - !ruby/object:Gem::Version
376
- version: '0.22'
394
+ version: '1.3'
377
395
  type: :development
378
396
  prerelease: false
379
397
  version_requirements: !ruby/object:Gem::Requirement
380
398
  requirements:
381
399
  - - "~>"
382
400
  - !ruby/object:Gem::Version
383
- version: '0.22'
401
+ version: '1.3'
384
402
  - !ruby/object:Gem::Dependency
385
403
  name: simplecov-cobertura
386
404
  requirement: !ruby/object:Gem::Requirement
387
405
  requirements:
388
406
  - - "~>"
389
407
  - !ruby/object:Gem::Version
390
- version: '2.1'
408
+ version: '4.0'
391
409
  type: :development
392
410
  prerelease: false
393
411
  version_requirements: !ruby/object:Gem::Requirement
394
412
  requirements:
395
413
  - - "~>"
396
414
  - !ruby/object:Gem::Version
397
- version: '2.1'
415
+ version: '4.0'
398
416
  - !ruby/object:Gem::Dependency
399
417
  name: solargraph
400
418
  requirement: !ruby/object:Gem::Requirement
@@ -446,6 +464,7 @@ files:
446
464
  - db/migrate/20210106060335_create_nonces.rb
447
465
  - db/migrate/20250823073342_add_public_key_hash_to_accounts.rb
448
466
  - db/migrate/20260716000000_add_revocation_to_certificates.rb
467
+ - db/migrate/20261102000000_add_created_at_index_to_nonces.rb
449
468
  - db/schema.rb
450
469
  - lib/bullion.rb
451
470
  - lib/bullion/acme/error.rb