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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +40 -3
- data/config.ru +5 -1
- data/db/migrate/20261102000000_add_created_at_index_to_nonces.rb +8 -0
- data/db/schema.rb +2 -1
- data/lib/bullion/acme/error.rb +10 -0
- data/lib/bullion/helpers/acme.rb +139 -3
- data/lib/bullion/helpers/ssl.rb +17 -2
- data/lib/bullion/models/authorization.rb +16 -0
- data/lib/bullion/models/certificate.rb +6 -1
- data/lib/bullion/models/challenge.rb +6 -0
- data/lib/bullion/models/nonce.rb +43 -4
- data/lib/bullion/models/order.rb +13 -0
- data/lib/bullion/service.rb +67 -6
- data/lib/bullion/services/ca.rb +43 -26
- data/lib/bullion/version.rb +1 -1
- data/lib/bullion.rb +71 -7
- metadata +36 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9cef665959a8cadbb7227013c2680c914772f9f61a33c230fd405f0d9f0a414a
|
|
4
|
+
data.tar.gz: 106020f0549902f1f3966270a4ba9cfc135935ccc9fe1969dd6671a9dcb91738
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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
|
-
|
|
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
|
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:
|
|
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
|
|
data/lib/bullion/acme/error.rb
CHANGED
|
@@ -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"
|
data/lib/bullion/helpers/acme.rb
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/bullion/helpers/ssl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/bullion/models/nonce.rb
CHANGED
|
@@ -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
|
|
16
|
-
def self.clean_up!
|
|
17
|
-
|
|
18
|
-
|
|
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
|
data/lib/bullion/models/order.rb
CHANGED
|
@@ -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
|
data/lib/bullion/service.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
data/lib/bullion/services/ca.rb
CHANGED
|
@@ -9,8 +9,7 @@ module Bullion
|
|
|
9
9
|
helpers Helpers::Ssl
|
|
10
10
|
|
|
11
11
|
before do
|
|
12
|
-
Models::Nonce.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
381
|
+
cert = OpenSSL::X509::Certificate.new(
|
|
382
|
+
Base64.urlsafe_decode64(extract_payload_data["certificate"])
|
|
383
|
+
)
|
|
380
384
|
|
|
381
|
-
#
|
|
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, {
|
data/lib/bullion/version.rb
CHANGED
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
|
-
|
|
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 ||=
|
|
75
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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:
|
|
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: '
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|