bullion 0.11.2 → 0.11.6
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/.ruby-version +1 -1
- data/CHANGELOG.md +38 -0
- data/README.md +132 -16
- data/db/migrate/20260716000000_add_revocation_to_certificates.rb +10 -0
- data/db/schema.rb +23 -20
- data/lib/bullion/acme/error.rb +15 -0
- data/lib/bullion/helpers/acme.rb +19 -2
- data/lib/bullion/models/certificate.rb +5 -0
- data/lib/bullion/service.rb +1 -0
- data/lib/bullion/services/ca.rb +56 -35
- data/lib/bullion/services/ping.rb +1 -0
- data/lib/bullion/version.rb +1 -1
- data/lib/bullion.rb +15 -2
- metadata +51 -32
- data/.github/workflows/ci.yml +0 -54
- data/.github/workflows/release.yml +0 -56
- data/.gitignore +0 -15
- data/.images/logo.png +0 -0
- data/.release-please-manifest.json +0 -3
- data/.rspec +0 -2
- data/.rubocop.yml +0 -79
- data/CODE_OF_CONDUCT.md +0 -74
- data/Dockerfile +0 -50
- data/Gemfile +0 -8
- data/Gemfile.lock +0 -290
- data/bullion.gemspec +0 -57
- data/release-please-config.json +0 -15
- data/scripts/build.sh +0 -3
- data/scripts/docker-entrypoint.sh +0 -4
- data/scripts/publish.sh +0 -7
- data/scripts/release.sh +0 -9
- data/scripts/test.sh +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44ad24a873adf3bab2e535688588282797d477c7f973835094b52d00cb9c6592
|
|
4
|
+
data.tar.gz: ee339845aaaca5001f5a33f21c75448c8d59b19893191bbc91055d41b13edc66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f663b5f7aedb812648c9065fd324ab0ab70a49b4ecb58c2b1160e12c84c93a5e340512be14812ec1e9d6790cce41344182df20e942d4ade2383ff81cf686dae
|
|
7
|
+
data.tar.gz: 7145c2c82d0e710de29bfc97dbe481464696d74a5381ac4dd55e56bd06605dc225dc6bd2a0593df459413d962c9583f5b3e6d133757b1e5ed75654af880b271f
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.10
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.6](https://github.com/jgnagy/bullion/compare/bullion/v0.11.5...bullion/v0.11.6) (2026-10-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* give each request fiber its own database connection ([c47a277](https://github.com/jgnagy/bullion/commit/c47a2779ab6b115bbb0c9af75559c17010353962))
|
|
9
|
+
* give each request fiber its own database connection ([cca3b31](https://github.com/jgnagy/bullion/commit/cca3b313a6adf04ef9680698ca64f53027b58d3b))
|
|
10
|
+
* return orderNotReady instead of badCSR for non-ready finalize ([2d5fc6f](https://github.com/jgnagy/bullion/commit/2d5fc6f15f98ba39253b294868674073d56e5f81))
|
|
11
|
+
|
|
12
|
+
## [0.11.5](https://github.com/jgnagy/bullion/compare/bullion/v0.11.4...bullion/v0.11.5) (2026-09-21)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **ci:** disable gem attestations to unblock release ([d850693](https://github.com/jgnagy/bullion/commit/d850693bc3ca6c58b15471cea773f0c58df1b0b1))
|
|
18
|
+
* **ci:** disable gem attestations to unblock release ([5bd3675](https://github.com/jgnagy/bullion/commit/5bd367593fc1bc4af1c71f27d2ed4bedbff2ad5c))
|
|
19
|
+
|
|
20
|
+
## [0.11.4](https://github.com/jgnagy/bullion/compare/bullion/v0.11.3...bullion/v0.11.4) (2026-09-21)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* package gem files without relying on git metadata ([ef70f0a](https://github.com/jgnagy/bullion/commit/ef70f0a42285aab8ae8bde1f8f0063e6a0ad38cb))
|
|
26
|
+
* package gem files without relying on git metadata ([8515841](https://github.com/jgnagy/bullion/commit/851584156440fef014503a9b3320049554a9083e))
|
|
27
|
+
|
|
28
|
+
## [0.11.3](https://github.com/jgnagy/bullion/compare/bullion/v0.11.2...bullion/v0.11.3) (2026-07-16)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Features
|
|
32
|
+
|
|
33
|
+
* implement revokeCert endpoint (closes [#1](https://github.com/jgnagy/bullion/issues/1)) ([#107](https://github.com/jgnagy/bullion/issues/107)) ([b08c65a](https://github.com/jgnagy/bullion/commit/b08c65a7cbb62abef34d7a24fe7f89124fae4743))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Bug Fixes
|
|
37
|
+
|
|
38
|
+
* replace undefined CERT_VALIDITY_DURATION constant with config call ([f858418](https://github.com/jgnagy/bullion/commit/f85841818f6d3511e436c010af86715433c57922))
|
|
39
|
+
* replace undefined CERT_VALIDITY_DURATION constant with config call ([8dc0ed0](https://github.com/jgnagy/bullion/commit/8dc0ed0dabdca4d50f720e22ce7d6825233042bb)), closes [#101](https://github.com/jgnagy/bullion/issues/101)
|
|
40
|
+
|
|
3
41
|
## [0.11.2](https://github.com/jgnagy/bullion/compare/bullion/v0.11.1...bullion/v0.11.2) (2026-02-16)
|
|
4
42
|
|
|
5
43
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Bullion is an [ACMEv2](https://tools.ietf.org/html/rfc8555)-compatible Certificate Authority (just like [Let's Encrypt](https://letsencrypt.org/)). Bullion makes it easy to leverage open standards for provisioning certificates for internal sites and services. Things like [cert-manager](https://cert-manager.io/), [certbot](https://certbot.eff.org/), and many other technologies work great with Let's Encrypt for _external_ hostnames, but for private domains and servers that aren't Internet accessible, there are few easy options.
|
|
6
6
|
|
|
7
|
-
Bullion installs easily (both as a ruby gem or a Docker image) and
|
|
7
|
+
Bullion installs easily (both as a ruby gem or a Docker image) and works with either [SQLite3](https://sqlite.org/index.html) for very small sites (where HA and high-throughput isn't a concern) or with [MariaDB](https://mariadb.org/)/[MySQL](https://www.mysql.com/) for production deployments.
|
|
8
8
|
|
|
9
9
|
The goal of the Bullion project is to make running a scalable, internal ACMEv2 CA easy. Either make a custom root CA certificate or give Bullion a signed intermediary to use in simple, compatible PEM format and you're up and running.
|
|
10
10
|
|
|
@@ -20,9 +20,133 @@ The goal of the Bullion project is to make running a scalable, internal ACMEv2 C
|
|
|
20
20
|
|
|
21
21
|
## Usage
|
|
22
22
|
|
|
23
|
-
###
|
|
23
|
+
### Prerequisites
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Before running Bullion, you need a CA key pair. Bullion requires two PEM files:
|
|
26
|
+
|
|
27
|
+
- **`tls.key`** — An encrypted private key (RSA, ECDSA, or EdDSA) for signing certificates. Bullion supports encrypted PEM keys using AES-128-CBC.
|
|
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
|
+
|
|
30
|
+
You can generate these with OpenSSL. For example, a self-signed root CA:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Generate an encrypted RSA private key
|
|
34
|
+
openssl genrsa -aes128 -out tls.key 4096
|
|
35
|
+
|
|
36
|
+
# Create a self-signed root certificate (5 years)
|
|
37
|
+
openssl req -x509 -new -key tls.key -sha256 -days 1825 \
|
|
38
|
+
-out tls.crt -subj "/DC=example/DC=com/CN=Bullion Root CA"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For production, you'll typically want an intermediate CA signed by your organization's root CA. Provide the intermediate certificate and its private key to Bullion, and include the root CA certificate in `tls.crt` so clients receive the full chain.
|
|
42
|
+
|
|
43
|
+
### Running with Docker
|
|
44
|
+
|
|
45
|
+
The quickest way to run Bullion is with the official Docker image:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
docker run -d \
|
|
49
|
+
--name bullion \
|
|
50
|
+
-p 9292:9292 \
|
|
51
|
+
-v /path/to/ssl:/ssl:ro \
|
|
52
|
+
-v /path/to/data:/data \
|
|
53
|
+
-e CA_DIR=/ssl \
|
|
54
|
+
-e CA_SECRET=your-key-password \
|
|
55
|
+
-e CA_DOMAINS=example.com \
|
|
56
|
+
-e DATABASE_URL=sqlite3:/data/bullion.db \
|
|
57
|
+
jgnagy/bullion:latest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This starts Bullion on port 9292 with:
|
|
61
|
+
|
|
62
|
+
- CA key pair mounted from `/path/to/ssl` (containing `tls.key` and `tls.crt`)
|
|
63
|
+
- SQLite database persisted at `/path/to/data/bullion.db`
|
|
64
|
+
- Certificate signing restricted to `example.com` and its subdomains
|
|
65
|
+
|
|
66
|
+
Verify it's running:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl http://localhost:9292/ping
|
|
70
|
+
# {"status":"up"}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For MySQL/MariaDB, use a `trilogy://` connection URL instead:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
-e DATABASE_URL=trilogy://user:password@mariadb:3306/bullion
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Running as a Gem
|
|
80
|
+
|
|
81
|
+
Install the gem:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gem install bullion
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Bullion is a Rack application served by [Itsi](https://itsi.fyi/). After installing the gem, you'll need a `config.ru` and an `Itsi.rb` configuration file. The simplest approach is to create a working directory with your CA key pair and a minimal Rack config:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
mkdir bullion-server && cd bullion-server
|
|
91
|
+
|
|
92
|
+
# Place your CA key pair here
|
|
93
|
+
cp /path/to/tls.key .
|
|
94
|
+
cp /path/to/tls.crt
|
|
95
|
+
|
|
96
|
+
# Create a minimal config.ru
|
|
97
|
+
cat > config.ru <<'RUBY'
|
|
98
|
+
# frozen_string_literal: true
|
|
99
|
+
|
|
100
|
+
require "bullion"
|
|
101
|
+
Bullion.validate_config!
|
|
102
|
+
|
|
103
|
+
require "prometheus/middleware/collector"
|
|
104
|
+
require "prometheus/middleware/exporter"
|
|
105
|
+
|
|
106
|
+
use Rack::ShowExceptions
|
|
107
|
+
use Rack::Deflater
|
|
108
|
+
use Prometheus::Middleware::Collector
|
|
109
|
+
use Prometheus::Middleware::Exporter
|
|
110
|
+
|
|
111
|
+
mappings = {
|
|
112
|
+
"/ping" => Bullion::Services::Ping.new,
|
|
113
|
+
"/acme" => Bullion::Services::CA.new
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
run Rack::URLMap.new(mappings)
|
|
117
|
+
RUBY
|
|
118
|
+
|
|
119
|
+
# Initialize Itsi and run database migrations
|
|
120
|
+
itsi init
|
|
121
|
+
DATABASE_URL=sqlite3:./bullion.db bullion-exec rake db:migrate
|
|
122
|
+
|
|
123
|
+
# Start the server
|
|
124
|
+
CA_DIR=. CA_SECRET=your-key-password CA_DOMAINS=example.com \
|
|
125
|
+
DATABASE_URL=sqlite3:./bullion.db itsi
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Database Setup
|
|
129
|
+
|
|
130
|
+
Bullion uses ActiveRecord migrations to create its schema. The migrations are included with the gem. Run them before starting the server:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
DATABASE_URL=sqlite3:./bullion.db bundle exec rake db:migrate
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
When using Docker, the included entrypoint handles this automatically on first start. If you're running the gem directly, you may need to run migrations manually after installing the gem.
|
|
137
|
+
|
|
138
|
+
### Quick Local Demo
|
|
139
|
+
|
|
140
|
+
For testing or evaluation, the Rakefile includes a `local_demo` task that generates a temporary CA key pair, runs migrations, and starts the server in the foreground:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/jgnagy/bullion.git
|
|
144
|
+
cd bullion
|
|
145
|
+
bundle install
|
|
146
|
+
bundle exec rake local_demo
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This creates a self-signed root CA and intermediate certificate in `tmp/`, uses an SQLite database in `tmp/db/`, and starts Bullion on port 9292. Press `Ctrl+C` to stop.
|
|
26
150
|
|
|
27
151
|
### Configuration Options
|
|
28
152
|
|
|
@@ -36,7 +160,7 @@ Whether run locally or via Docker, the following environment variables configure
|
|
|
36
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. |
|
|
37
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. |
|
|
38
162
|
| `CERT_VALIDITY_DURATION` | `7776000` | How long should issued certs be valid (in seconds)? Default is 90 days. |
|
|
39
|
-
| `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). |
|
|
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`. |
|
|
40
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. |
|
|
41
165
|
| `LOG_LEVEL` | `warn` | Log level for Bullion. Supported levels (starting with the noisiest) are debug, info, warn, error, and fatal. |
|
|
42
166
|
| `BULLION_PORT` | `9292` | TCP port Bullion will listen on. |
|
|
@@ -45,6 +169,8 @@ Whether run locally or via Docker, the following environment variables configure
|
|
|
45
169
|
| `WORKER_MEMORY_LIMIT` | `1024**3` | [Itsi worker memory limit](https://itsi.fyi/options/worker_memory_limit/) for each worker process (in bytes). Default is 1GiB. |
|
|
46
170
|
| `RACK_ENV` | `production`* | When run via Docker, the default is `production`, when run via `rake local_demo` it is `development`. Used to tell Bullion if it is run in development mode or for testing. |
|
|
47
171
|
|
|
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
|
+
|
|
48
174
|
### Integrating
|
|
49
175
|
|
|
50
176
|
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.
|
|
@@ -57,20 +183,10 @@ Bullion provides a `/ping` endpoint that should respond with `{ 'status': 'up' }
|
|
|
57
183
|
|
|
58
184
|
Prometheus metrics are also scrapable at `/metrics`. This includes typical web request information as well as latencies related to the different Challenge types.
|
|
59
185
|
|
|
60
|
-
## Development
|
|
186
|
+
## Development & Contributing
|
|
61
187
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
65
|
-
|
|
66
|
-
## Contributing
|
|
67
|
-
|
|
68
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/jgnagy/bullion. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
188
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, testing, style guidelines, and pull request expectations. Guidelines for AI agents and automated tools are in [`AGENTS.md`](AGENTS.md).
|
|
69
189
|
|
|
70
190
|
## License
|
|
71
191
|
|
|
72
192
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
73
|
-
|
|
74
|
-
## Code of Conduct
|
|
75
|
-
|
|
76
|
-
Everyone interacting in the Bullion project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jgnagy/bullion/blob/master/CODE_OF_CONDUCT.md).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adds revocation tracking columns to the certificates table
|
|
4
|
+
class AddRevocationToCertificates < ActiveRecord::Migration[8.0]
|
|
5
|
+
def change
|
|
6
|
+
add_column :certificates, :revoked, :boolean, default: false, null: false
|
|
7
|
+
add_column :certificates, :revoked_at, :datetime
|
|
8
|
+
add_column :certificates, :revocation_reason, :integer
|
|
9
|
+
end
|
|
10
|
+
end
|
data/db/schema.rb
CHANGED
|
@@ -10,24 +10,24 @@
|
|
|
10
10
|
#
|
|
11
11
|
# It's strongly recommended that you check this file into your version control system.
|
|
12
12
|
|
|
13
|
-
ActiveRecord::Schema[8.
|
|
13
|
+
ActiveRecord::Schema[8.1].define(version: 2026_07_16_000000) do
|
|
14
14
|
create_table "accounts", force: :cascade do |t|
|
|
15
|
-
t.boolean "tos_agreed", default: true, null: false
|
|
16
|
-
t.text "public_key", null: false
|
|
17
15
|
t.text "contacts", null: false
|
|
18
16
|
t.datetime "created_at", null: false
|
|
19
|
-
t.
|
|
17
|
+
t.text "public_key", null: false
|
|
20
18
|
t.string "public_key_hash", limit: 44, null: false
|
|
19
|
+
t.boolean "tos_agreed", default: true, null: false
|
|
20
|
+
t.datetime "updated_at", null: false
|
|
21
21
|
t.index ["public_key_hash"], name: "index_accounts_on_public_key_hash", unique: true
|
|
22
22
|
t.index ["tos_agreed"], name: "index_accounts_on_tos_agreed"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
create_table "authorizations", force: :cascade do |t|
|
|
26
|
-
t.
|
|
26
|
+
t.datetime "created_at", null: false
|
|
27
27
|
t.datetime "expires", null: false
|
|
28
28
|
t.text "identifier", null: false
|
|
29
29
|
t.bigint "order_id"
|
|
30
|
-
t.
|
|
30
|
+
t.string "status", default: "pending", null: false
|
|
31
31
|
t.datetime "updated_at", null: false
|
|
32
32
|
t.index ["expires"], name: "index_authorizations_on_expires"
|
|
33
33
|
t.index ["order_id"], name: "index_authorizations_on_order_id"
|
|
@@ -35,15 +35,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_073342) do
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
create_table "certificates", force: :cascade do |t|
|
|
38
|
-
t.
|
|
38
|
+
t.text "alternate_names"
|
|
39
|
+
t.datetime "created_at", null: false
|
|
39
40
|
t.string "csr_fingerprint", null: false
|
|
40
41
|
t.text "data", null: false
|
|
41
|
-
t.text "alternate_names"
|
|
42
42
|
t.string "requester"
|
|
43
|
-
t.
|
|
43
|
+
t.integer "revocation_reason"
|
|
44
|
+
t.boolean "revoked", default: false, null: false
|
|
45
|
+
t.datetime "revoked_at"
|
|
44
46
|
t.bigint "serial", null: false
|
|
45
|
-
t.
|
|
47
|
+
t.string "subject", null: false
|
|
46
48
|
t.datetime "updated_at", null: false
|
|
49
|
+
t.boolean "validated", default: false, null: false
|
|
47
50
|
t.index ["csr_fingerprint"], name: "index_certificates_on_csr_fingerprint"
|
|
48
51
|
t.index ["serial"], name: "index_certificates_on_serial", unique: true
|
|
49
52
|
t.index ["subject"], name: "index_certificates_on_subject"
|
|
@@ -52,13 +55,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_073342) do
|
|
|
52
55
|
|
|
53
56
|
create_table "challenges", force: :cascade do |t|
|
|
54
57
|
t.string "acme_type", null: false
|
|
55
|
-
t.string "status", default: "pending", null: false
|
|
56
|
-
t.datetime "expires", null: false
|
|
57
|
-
t.string "token", null: false
|
|
58
|
-
t.datetime "validated"
|
|
59
58
|
t.bigint "authorization_id"
|
|
60
59
|
t.datetime "created_at", null: false
|
|
60
|
+
t.datetime "expires", null: false
|
|
61
|
+
t.string "status", default: "pending", null: false
|
|
62
|
+
t.string "token", null: false
|
|
61
63
|
t.datetime "updated_at", null: false
|
|
64
|
+
t.datetime "validated"
|
|
62
65
|
t.index ["acme_type"], name: "index_challenges_on_acme_type"
|
|
63
66
|
t.index ["authorization_id"], name: "index_challenges_on_authorization_id"
|
|
64
67
|
t.index ["expires"], name: "index_challenges_on_expires"
|
|
@@ -66,21 +69,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_073342) do
|
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
create_table "nonces", force: :cascade do |t|
|
|
69
|
-
t.string "token", null: false
|
|
70
72
|
t.datetime "created_at", null: false
|
|
73
|
+
t.string "token", null: false
|
|
71
74
|
t.datetime "updated_at", null: false
|
|
72
75
|
t.index ["token"], name: "index_nonces_on_token", unique: true
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
create_table "orders", force: :cascade do |t|
|
|
76
|
-
t.
|
|
79
|
+
t.bigint "account_id"
|
|
80
|
+
t.bigint "certificate_id"
|
|
81
|
+
t.datetime "created_at", null: false
|
|
77
82
|
t.datetime "expires", null: false
|
|
78
83
|
t.text "identifiers", null: false
|
|
79
|
-
t.datetime "not_before", null: false
|
|
80
84
|
t.datetime "not_after", null: false
|
|
81
|
-
t.
|
|
82
|
-
t.
|
|
83
|
-
t.datetime "created_at", null: false
|
|
85
|
+
t.datetime "not_before", null: false
|
|
86
|
+
t.string "status", default: "pending", null: false
|
|
84
87
|
t.datetime "updated_at", null: false
|
|
85
88
|
t.index ["account_id"], name: "index_orders_on_account_id"
|
|
86
89
|
t.index ["certificate_id"], name: "index_orders_on_certificate_id"
|
data/lib/bullion/acme/error.rb
CHANGED
|
@@ -39,6 +39,16 @@ module Bullion
|
|
|
39
39
|
def acme_type = "badPublicKey"
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# ACME exception for bad certificates
|
|
43
|
+
class BadCertificate < Bullion::Acme::Error
|
|
44
|
+
def acme_type = "badCertificate"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ACME exception for already-revoked certificates
|
|
48
|
+
class AlreadyRevoked < Bullion::Acme::Error
|
|
49
|
+
def acme_type = "alreadyRevoked"
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
# ACME exception for invalid contacts in accounts
|
|
43
53
|
class InvalidContact < Bullion::Acme::Error
|
|
44
54
|
def acme_type = "invalidContact"
|
|
@@ -54,6 +64,11 @@ module Bullion
|
|
|
54
64
|
def acme_type = "malformed"
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
# ACME exception for orders that aren't ready to be finalized
|
|
68
|
+
class OrderNotReady < Bullion::Acme::Error
|
|
69
|
+
def acme_type = "orderNotReady"
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
# ACME exception for unsupported contacts in accounts
|
|
58
73
|
class UnsupportedContact < Bullion::Acme::Error
|
|
59
74
|
def acme_type = "unsupportedContact"
|
data/lib/bullion/helpers/acme.rb
CHANGED
|
@@ -125,7 +125,6 @@ module Bullion
|
|
|
125
125
|
# Make sure the CSR has a valid public key
|
|
126
126
|
raise Bullion::Acme::Errors::BadCsr unless csr.verify(csr.public_key)
|
|
127
127
|
|
|
128
|
-
return false unless order.ready_status?
|
|
129
128
|
raise Bullion::Acme::Errors::BadCsr if csr_cn && !csr_domains.include?(csr_cn)
|
|
130
129
|
raise Bullion::Acme::Errors::BadCsr unless csr_domains.sort == order.domains.sort
|
|
131
130
|
|
|
@@ -174,7 +173,7 @@ module Bullion
|
|
|
174
173
|
# don't allow nonsense certs
|
|
175
174
|
raise Bullion::Acme::Errors::InvalidOrder unless nb < na
|
|
176
175
|
# don't allow far-future certs
|
|
177
|
-
if nb > Time.now + (7 * 86_400) || na > Time.now +
|
|
176
|
+
if nb > Time.now + (7 * 86_400) || na > Time.now + Bullion.config.ca.cert_validity_duration
|
|
178
177
|
raise Bullion::Acme::Errors::InvalidOrder
|
|
179
178
|
end
|
|
180
179
|
|
|
@@ -192,6 +191,24 @@ module Bullion
|
|
|
192
191
|
Bullion.config.ca.domains.none? { domain["value"].end_with?(it) }
|
|
193
192
|
end
|
|
194
193
|
end
|
|
194
|
+
|
|
195
|
+
# Builds the ACMEv2 order resource representation
|
|
196
|
+
# @see https://tools.ietf.org/html/rfc8555#section-7.1.3
|
|
197
|
+
def order_data(order)
|
|
198
|
+
data = {
|
|
199
|
+
status: order.status,
|
|
200
|
+
expires: order.expires,
|
|
201
|
+
notBefore: order.not_before,
|
|
202
|
+
notAfter: order.not_after,
|
|
203
|
+
identifiers: order.identifiers,
|
|
204
|
+
authorizations: order.authorizations.map { uri("/authorizations/#{it.id}") },
|
|
205
|
+
finalize: uri("/orders/#{order.id}/finalize")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
data[:certificate] = uri("/certificates/#{order.certificate.id}") if order.valid_status?
|
|
209
|
+
|
|
210
|
+
data
|
|
211
|
+
end
|
|
195
212
|
end
|
|
196
213
|
end
|
|
197
214
|
end
|
|
@@ -24,6 +24,11 @@ module Bullion
|
|
|
24
24
|
subject.split("/").grep(/^CN=/).first.split("=").last
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Returns true if the certificate has been revoked
|
|
28
|
+
def revoked?
|
|
29
|
+
revoked == true
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
def self.from_csr(csr)
|
|
28
33
|
subjt = csr.subject if csr.subject && !csr.subject.to_s.empty?
|
|
29
34
|
|
data/lib/bullion/service.rb
CHANGED
data/lib/bullion/services/ca.rb
CHANGED
|
@@ -66,6 +66,10 @@ module Bullion
|
|
|
66
66
|
halt 200
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
options "/revokecert" do
|
|
70
|
+
halt 200
|
|
71
|
+
end
|
|
72
|
+
|
|
69
73
|
# Non-standard endpoint that returns the CA bundle for Bullion
|
|
70
74
|
# Trusting this bundle should be sufficient to trust all Bullion-issued certs
|
|
71
75
|
options "/cabundle" do
|
|
@@ -210,15 +214,7 @@ module Bullion
|
|
|
210
214
|
content_type "application/json"
|
|
211
215
|
add_acme_headers @new_nonce, additional: { "Location" => uri("/orders/#{order.id}") }
|
|
212
216
|
|
|
213
|
-
halt 201,
|
|
214
|
-
status: order.status,
|
|
215
|
-
expires: order.expires,
|
|
216
|
-
notBefore: order.not_before,
|
|
217
|
-
notAfter: order.not_after,
|
|
218
|
-
identifiers: order.identifiers,
|
|
219
|
-
authorizations: order.authorizations.map { uri("/authorizations/#{it.id}") },
|
|
220
|
-
finalize: uri("/orders/#{order.id}/finalize")
|
|
221
|
-
}.to_json
|
|
217
|
+
halt 201, order_data(order).to_json
|
|
222
218
|
rescue Bullion::Acme::Error => e
|
|
223
219
|
content_type "application/problem+json"
|
|
224
220
|
halt 400, { type: e.acme_error, detail: e.message }.to_json
|
|
@@ -233,19 +229,7 @@ module Bullion
|
|
|
233
229
|
|
|
234
230
|
order = Models::Order.find(params[:id])
|
|
235
231
|
|
|
236
|
-
|
|
237
|
-
status: order.status,
|
|
238
|
-
expires: order.expires,
|
|
239
|
-
notBefore: order.not_before,
|
|
240
|
-
notAfter: order.not_after,
|
|
241
|
-
identifiers: order.identifiers,
|
|
242
|
-
authorizations: order.authorizations.map { uri("/authorizations/#{it.id}") },
|
|
243
|
-
finalize: uri("/orders/#{order.id}/finalize")
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
data[:certificate] = uri("/certificates/#{order.certificate.id}") if order.valid_status?
|
|
247
|
-
|
|
248
|
-
data.to_json
|
|
232
|
+
order_data(order).to_json
|
|
249
233
|
rescue Bullion::Acme::Error => e
|
|
250
234
|
content_type "application/problem+json"
|
|
251
235
|
halt 400, { type: e.acme_error, detail: e.message }.to_json
|
|
@@ -263,6 +247,17 @@ module Bullion
|
|
|
263
247
|
|
|
264
248
|
order_csr = Models::OrderCsr.from_acme_request(order, @payload_data["csr"])
|
|
265
249
|
|
|
250
|
+
unless order.ready_status?
|
|
251
|
+
# Finalize is idempotent for orders that have already been finalized
|
|
252
|
+
halt 200, order_data(order).to_json if %w[processing valid].include?(order.status)
|
|
253
|
+
|
|
254
|
+
content_type "application/problem+json"
|
|
255
|
+
halt 403, {
|
|
256
|
+
type: Bullion::Acme::Errors::OrderNotReady.new.acme_error,
|
|
257
|
+
detail: "Order is not ready to be finalized"
|
|
258
|
+
}.to_json
|
|
259
|
+
end
|
|
260
|
+
|
|
266
261
|
unless acme_csr_valid?(order_csr)
|
|
267
262
|
content_type "application/problem+json"
|
|
268
263
|
halt 400, {
|
|
@@ -277,19 +272,7 @@ module Bullion
|
|
|
277
272
|
order.status = "valid"
|
|
278
273
|
order.save
|
|
279
274
|
|
|
280
|
-
|
|
281
|
-
status: order.status,
|
|
282
|
-
expires: order.expires,
|
|
283
|
-
notBefore: order.not_before,
|
|
284
|
-
notAfter: order.not_after,
|
|
285
|
-
identifiers: order.identifiers,
|
|
286
|
-
authorizations: order.authorizations.map { uri("/authorizations/#{it.id}") },
|
|
287
|
-
finalize: uri("/orders/#{order.id}/finalize")
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
data[:certificate] = uri("/certificates/#{order.certificate.id}") if order.valid_status?
|
|
291
|
-
|
|
292
|
-
data.to_json
|
|
275
|
+
order_data(order).to_json
|
|
293
276
|
rescue Bullion::Acme::Error => e
|
|
294
277
|
content_type "application/problem+json"
|
|
295
278
|
halt 422, { type: e.acme_error, detail: e.message }.to_json
|
|
@@ -384,6 +367,44 @@ module Bullion
|
|
|
384
367
|
halt(422, { error: "Order not valid" }.to_json)
|
|
385
368
|
end
|
|
386
369
|
end
|
|
370
|
+
|
|
371
|
+
# Revokes a certificate
|
|
372
|
+
# @see https://tools.ietf.org/html/rfc8555#section-7.6
|
|
373
|
+
post "/revokecert" do
|
|
374
|
+
parse_acme_jwt
|
|
375
|
+
add_acme_headers @new_nonce
|
|
376
|
+
|
|
377
|
+
# The certificate is base64url-encoded DER
|
|
378
|
+
cert_der = Base64.urlsafe_decode64(@payload_data["certificate"])
|
|
379
|
+
cert = OpenSSL::X509::Certificate.new(cert_der)
|
|
380
|
+
|
|
381
|
+
# Find the certificate in the database by matching the PEM data
|
|
382
|
+
record = Models::Certificate.find_by(serial: cert.serial.to_i)
|
|
383
|
+
|
|
384
|
+
unless record
|
|
385
|
+
content_type "application/problem+json"
|
|
386
|
+
halt 400, {
|
|
387
|
+
type: Bullion::Acme::Errors::BadCertificate.new.acme_error,
|
|
388
|
+
detail: "Certificate not found"
|
|
389
|
+
}.to_json
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
if record.revoked?
|
|
393
|
+
content_type "application/problem+json"
|
|
394
|
+
halt 400, {
|
|
395
|
+
type: Bullion::Acme::Errors::AlreadyRevoked.new.acme_error,
|
|
396
|
+
detail: "Certificate has already been revoked"
|
|
397
|
+
}.to_json
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
reason = @payload_data["reason"]
|
|
401
|
+
record.update!(revoked: true, revoked_at: Time.now, revocation_reason: reason)
|
|
402
|
+
|
|
403
|
+
halt 200
|
|
404
|
+
rescue Bullion::Acme::Error => e
|
|
405
|
+
content_type "application/problem+json"
|
|
406
|
+
halt 400, { type: e.acme_error, detail: e.message }.to_json
|
|
407
|
+
end
|
|
387
408
|
end
|
|
388
409
|
end
|
|
389
410
|
end
|
data/lib/bullion/version.rb
CHANGED
data/lib/bullion.rb
CHANGED
|
@@ -107,11 +107,11 @@ module Bullion
|
|
|
107
107
|
raise ConfigError,
|
|
108
108
|
"Invalid Cert Path: #{config.ca.cert_path}"
|
|
109
109
|
end
|
|
110
|
-
if 60 * 60 * 24 * 397
|
|
110
|
+
if config.ca.cert_validity_duration > 60 * 60 * 24 * 397
|
|
111
111
|
raise ConfigError,
|
|
112
112
|
"Cert Validity Too Long"
|
|
113
113
|
end
|
|
114
|
-
if 60 * 60 * 24 * 2
|
|
114
|
+
if config.ca.cert_validity_duration < 60 * 60 * 24 * 2
|
|
115
115
|
raise ConfigError,
|
|
116
116
|
"Cert Validity Too Short"
|
|
117
117
|
end
|
|
@@ -140,6 +140,19 @@ Bullion.configure do |config|
|
|
|
140
140
|
config.nameservers = nameservers if nameservers
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# Itsi processes each request inside its own Fiber, but all fibers on a thread
|
|
144
|
+
# share an ActiveSupport execution context by default. ActiveRecord leases a
|
|
145
|
+
# database connection per execution context, so without fiber isolation every
|
|
146
|
+
# concurrent request fiber on a thread shares (and races on) the same
|
|
147
|
+
# connection. Trilogy detects this and raises Trilogy::SynchronizationError.
|
|
148
|
+
# Mirror Itsi's own scheduler_mode hook (which itsi-server 0.2.27 never loads)
|
|
149
|
+
# and the Rails 8.1+ default by opting into fiber isolation, honoring Itsi's
|
|
150
|
+
# opt-out.
|
|
151
|
+
# @see https://github.com/jgnagy/bullion/issues/116
|
|
152
|
+
unless ENV["ITSI_DISABLE_AS_AUTO_FIBER_ISOLATION_LEVEL"]
|
|
153
|
+
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
|
|
154
|
+
end
|
|
155
|
+
|
|
143
156
|
# Internal requirements
|
|
144
157
|
require "bullion/version"
|
|
145
158
|
require "bullion/acme/error"
|