ssl-test 2.0.0 → 2.0.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: 886440c38c24dd0ad30e2fff8552fe89aa924010e8f9218032d030eceb29a422
4
- data.tar.gz: b854a7f800c8aa3364ed78ebf29b4ec731e3904b7c33b682a39383a5096742d0
3
+ metadata.gz: 073a878a42dbba9b30e7c69ec4b435cd8f2c607e59a6b894099af73ea60526f8
4
+ data.tar.gz: 709b2f097b7d8a61922f2cfcee0cb6651046c57854dea0b3dc17c8367b7b856a
5
5
  SHA512:
6
- metadata.gz: 895f1193c0924d93b0bc4c29dea5d9de8b38b047f25c83273d13146c3bffa0a201676e320d6f4a6f373f231f87fdf0fda7bbef5a8983e40d489c8179097ed8dc
7
- data.tar.gz: c1d2d3cd0014d31e01456dd5f0d1e58a59e820bb77f8e15a2755a27421a107b63d6cc653f115a7e50f19b475f40b1961bbdccab3b92181f1a5f812f68a668604
6
+ metadata.gz: 150c1d97a77c0a37c71a180d7cd643f3a4264280f65261e2cb830b184fed00cb60458bf4d27ce1eb52225db506398d2e1480bf69d5a7d6ffc4ee7e5ad288a5b1
7
+ data.tar.gz: '08f66249c1b7d951b71283589d957b6bfe4451934ff155af255e4e31a5f77d0aa27bcd1dcc9cee1dcb0b1ddc450618d97c9a445c5067990009b14ad616933128'
@@ -3,6 +3,10 @@ on: [push]
3
3
  jobs:
4
4
  specs:
5
5
  runs-on: ubuntu-22.04
6
+ strategy:
7
+ fail-fast: false
8
+ matrix:
9
+ ruby-version: ['3.2', '3.3', '3.4']
6
10
  services:
7
11
  redis:
8
12
  image: redis
@@ -16,7 +20,7 @@ jobs:
16
20
  - name: Set up Ruby
17
21
  uses: ruby/setup-ruby@v1
18
22
  with:
19
- ruby-version: '3.1'
23
+ ruby-version: ${{ matrix.ruby-version }}
20
24
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
21
25
  - name: Run specs
22
26
  run: |
data/README.md CHANGED
@@ -70,6 +70,13 @@ cert # => #<OpenSSL::X509::Certificate...>
70
70
 
71
71
  If the CRL is missing, invalid or unreachable the certificate revocation will be tested using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol).
72
72
 
73
+ You can swap the order if you'd rather check OCSP first and only fall back to CRL on error (the default is CRL first, since 1.6, to reduce the revocation propagation delay):
74
+
75
+ ```ruby
76
+ SSLTest.revocation_order = %i[ocsp crl] # OCSP first, CRL fallback
77
+ SSLTest.revocation_order = %i[crl ocsp] # the default: CRL first, OCSP fallback
78
+ ```
79
+
73
80
  If both CRL and OCSP tests are impossible, the certificate will still be considered valid but with an error message:
74
81
 
75
82
  ```ruby
@@ -195,7 +202,8 @@ But also **revoked certs** like most browsers (not handled by `curl`)
195
202
 
196
203
  See also github releases: https://github.com/jarthod/ssl-test/releases
197
204
 
198
- * 2.0.0 - 2026-06-16: Make the cache backend configurable. The default stays an in-process `SSLTest::MemoryStore`, but you can now assign any object responding to the `Rails.cache`-style API (`read`/`write`/`delete`) with `SSLTest.cache = Rails.cache` to share responses across processes and get compression (e.g. memcache via Dalli see the memcached note in the Caching section about raising the max value size for large CRLs). **Breaking:** the module-level `SSLTest.cache_size` and `SSLTest.flush_cache` were removed use `SSLTest.cache.size` and `SSLTest.cache.clear` instead (these only work with the built-in `MemoryStore`; shared backends like `Rails.cache` can't be enumerated and shouldn't be wholesale-cleared)
205
+ * 2.0.1 - 2026-06-19: Speed up and shrink the memory use of CRL checks with a fast path that scans the raw CRL for the certificate's serial before parsing, avoiding instantiating the entire revocation list (>1M Ruby objects for busy CAs) when the cert isn't revoked. Send both `If-None-Match` and `If-Modified-Since` on CRL revalidation so CDN-backed CAs that don't honor their own ETag (e.g. DigiCert) still return a `304` instead of re-downloading the whole list.
206
+ * 2.0.0 - 2026-06-16: Make the revocation check order configurable via `SSLTest.revocation_order` (`%i[crl ocsp]` by default, set `%i[ocsp crl]` to check OCSP first). Make the cache backend configurable. The default stays an in-process `SSLTest::MemoryStore`, but you can now assign any object responding to the `Rails.cache`-style API (`read`/`write`/`delete`) with `SSLTest.cache = Rails.cache` to share responses across processes and get compression (e.g. memcache via Dalli — see the memcached note in the Caching section about raising the max value size for large CRLs). **Breaking:** the module-level `SSLTest.cache_size` and `SSLTest.flush_cache` were removed — use `SSLTest.cache.size` and `SSLTest.cache.clear` instead (these only work with the built-in `MemoryStore`; shared backends like `Rails.cache` can't be enumerated and shouldn't be wholesale-cleared)
199
207
  * 1.6.0 - 2026-06-16: Check revocation with CRL first and fall back to OCSP (was OCSP first) to reduce revocation detection delay
200
208
  * 1.5.0 - 2025-11-28: Add support for local certificates testing and HTTP proxies (#8), changed `#test` method into `#test_url` and `#test_cert` (`#test` remains as an alias for `#test_url` for backward-compatibility)
201
209
  * 1.4.1 - 2022-10-24: Add support for "tcps://" scheme
data/lib/ssl-test/crl.rb CHANGED
@@ -47,11 +47,19 @@ module SSLTest
47
47
  response = OpenSSL::X509::CRL.new http_response
48
48
  return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)
49
49
 
50
+ # Fast path: scan the raw response for the cert's serial encoded as DER.
51
+ # In most case (not revoked) this lets us skip response.revoked, which
52
+ # instantiate the *entire* revocation list as Ruby objects (>1M objects for busy CAs)
53
+ serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
54
+ return :crl_ok unless response.to_der.include?(serial_der)
55
+
56
+ # The serial's bytes appear (a real hit, or a rare collision):
57
+ # confirm authoritatively and pull the reason/date. The costly revoked-list
58
+ # materialisation only happens here, i.e. for actually-revoked certs.
50
59
  revoked = response.revoked.find { |r| r.serial == cert.serial }
51
60
  if revoked
52
61
  reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
53
62
  return [true, reason || "Unknown reason", revoked.time]
54
- else
55
63
  end
56
64
 
57
65
  :crl_ok
@@ -77,12 +85,13 @@ module SSLTest
77
85
  http.read_timeout = read_timeout
78
86
 
79
87
  req = Net::HTTP::Get.new(path)
80
- # Include conditional caching headers from cache to save bandwidth if list didn't change (304)
81
- if etag = cache_entry&.[](:etag)
82
- req["If-None-Match"] = etag
83
- elsif last_mod = cache_entry&.[](:last_mod)
84
- req["If-Modified-Since"] = last_mod
85
- end
88
+ # Include conditional caching headers from cache to save bandwidth if the
89
+ # list didn't change (304). Send both validators when present: some
90
+ # CDN-backed CAs (e.g. DigiCert) serve per-node ETags they won't honor via
91
+ # If-None-Match but will revalidate via If-Modified-Since, so sending only
92
+ # the ETag defeats the 304 and re-downloads the whole list every time.
93
+ req["If-None-Match"] = cache_entry[:etag] if cache_entry&.[](:etag)
94
+ req["If-Modified-Since"] = cache_entry[:last_mod] if cache_entry&.[](:last_mod)
86
95
  http_response = http.request(req)
87
96
  case http_response
88
97
  when Net::HTTPNotModified
@@ -93,7 +102,7 @@ module SSLTest
93
102
  when Net::HTTPSuccess
94
103
  # Success, update (or add to) cache and return frech body
95
104
  @logger&.debug { "SSLTest + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
96
- @logger&.warn { "SSLTest + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
105
+ @logger&.warn { "SSLTest + CRL: Warning: massive file size (#{http_response.body.bytesize} bytes)" } if http_response.body.bytesize > 1024**2 # 1MB
97
106
  @logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
98
107
  cache.write(cache_key, {
99
108
  body: http_response.body,
data/lib/ssl-test.rb CHANGED
@@ -11,7 +11,7 @@ module SSLTest
11
11
  extend OCSP
12
12
  extend CRL
13
13
 
14
- VERSION = -"2.0.0"
14
+ VERSION = -"2.0.1"
15
15
 
16
16
  # Prefix for all cache keys so SSLTest entries coexist cleanly inside a shared
17
17
  # cache (e.g. Rails.cache).
@@ -114,6 +114,23 @@ module SSLTest
114
114
  @logger = logger
115
115
  end
116
116
 
117
+ # The order in which revocation check methods are tried for each certificate.
118
+ # The first method to return a conclusive answer (ok or revoked) wins; the
119
+ # next is only tried when the previous one errors out (missing endpoint,
120
+ # network error, etc.). Defaults to CRL first (since 1.6) to reduce the
121
+ # revocation propagation delay. Set to %i[ocsp crl] to check OCSP first.
122
+ def revocation_order
123
+ @revocation_order ||= %i[crl ocsp]
124
+ end
125
+
126
+ def revocation_order= order
127
+ order = Array(order).map { |m| m.to_sym }
128
+ unless order.sort == %i[crl ocsp]
129
+ raise ArgumentError, "SSLTest.revocation_order must be %i[crl ocsp] or %i[ocsp crl], got #{order.inspect}"
130
+ end
131
+ @revocation_order = order
132
+ end
133
+
117
134
  private
118
135
 
119
136
  def revocation_message(revoked, revocation_date, message)
@@ -147,26 +164,37 @@ module SSLTest
147
164
  chain[0..-2].each_with_index do |cert, i|
148
165
  @logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
149
166
 
150
- # Try with CRL first
151
- crl_result = test_crl_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
152
- @logger&.debug { "SSLTest + CRL: #{crl_result}" }
153
- next if crl_result == :crl_ok # passed, go to next cert
154
- return crl_result if crl_result[0] == true # revoked
155
-
156
- # Otherwise it means there was an error so let's try with OCSP instead
157
- ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
158
- @logger&.debug { "SSLTest + OCSP: #{ocsp_result}" }
159
- next if ocsp_result == :ocsp_ok # passed, go to next cert
160
- return ocsp_result if ocsp_result[0] == true # revoked
167
+ # Try each revocation method in the configured order, falling back to the
168
+ # next one only when the current method errors out.
169
+ errors = {}
170
+ passed = false
171
+ revocation_order.each do |method|
172
+ result = test_revocation(method, cert, issuer: chain[i + 1], chain: chain, **options)
173
+ @logger&.debug { "SSLTest + #{method.to_s.upcase}: #{result}" }
174
+ if result == :"#{method}_ok" # passed, go to next cert
175
+ passed = true
176
+ break
177
+ end
178
+ return result if result[0] == true # revoked
179
+ errors[method] = result[1] # errored, try the next method
180
+ end
181
+ next if passed
161
182
 
162
- # If both method failed, return a soft fail with a combination of both error messages
163
- return [false, "CRL: #{crl_result[1]}, OCSP: #{ocsp_result[1]}", nil]
183
+ # If all methods failed, return a soft fail with a combination of the error messages
184
+ return [false, errors.map { |method, message| "#{method.to_s.upcase}: #{message}" }.join(", "), nil]
164
185
  end
165
186
 
166
187
  # If all test passed, the certificate is not revoked
167
188
  [false, nil, nil]
168
189
  end
169
190
 
191
+ def test_revocation method, cert, **options
192
+ case method
193
+ when :crl then test_crl_revocation(cert, **options)
194
+ when :ocsp then test_ocsp_revocation(cert, **options)
195
+ end
196
+ end
197
+
170
198
  def cert_field_to_hash field
171
199
  field.to_a.each.with_object({}) do |v, h|
172
200
  v = v.to_a
@@ -1,4 +1,5 @@
1
1
  require "ssl-test"
2
+ require "rspec/retry" # the cache.size examples below hit live CRL/OCSP endpoints
2
3
 
3
4
  describe SSLTest::MemoryStore do
4
5
  subject(:store) { described_class.new }
@@ -51,7 +52,7 @@ describe SSLTest::MemoryStore do
51
52
  end
52
53
 
53
54
  # #size as reported through the default store after real CRL/OCSP fetches.
54
- describe "SSLTest.cache.size" do
55
+ describe "SSLTest.cache.size", retry: 5 do # examples hit live CRL/OCSP endpoints
55
56
  before { SSLTest.cache.clear }
56
57
 
57
58
  it "returns 0 by default" do
@@ -12,8 +12,8 @@ RSpec.configure do |config|
12
12
  # The error/revocation examples below hit several public TLS test endpoints
13
13
  # (badssl.com, testserver.host, ssl.com) which intermittently reset connections
14
14
  # under load. They're spread across a few providers to avoid hammering a single
15
- # one, and examples tagged `:retry` are re-run a few times (via rspec-retry) so
16
- # transient network blips don't fail the suite.
15
+ # one, and the network-hitting describe blocks are tagged `retry: 5` (via
16
+ # rspec-retry) so transient network blips don't fail the suite.
17
17
  config.verbose_retry = true
18
18
  config.default_sleep_interval = 1
19
19
  end
@@ -26,7 +26,7 @@ describe SSLTest do
26
26
 
27
27
  after(:each) { proxy_thread&.kill }
28
28
 
29
- describe '.test_url' do
29
+ describe '.test_url', retry: 5 do # examples hit live TLS/CRL/OCSP endpoints
30
30
  it "returns no error on valid SNI website" do
31
31
  valid, error, cert = SSLTest.test("https://www.mycs.com")
32
32
  expect(error).to be_nil
@@ -59,35 +59,35 @@ describe SSLTest do
59
59
  expect(cert).to be_a OpenSSL::X509::Certificate
60
60
  end
61
61
 
62
- it "returns error on self signed certificate", :retry => 5 do
62
+ it "returns error on self signed certificate" do
63
63
  valid, error, cert = SSLTest.test("https://self-signed.testserver.host/")
64
64
  expect(error).to eq ("error code 18: self-signed certificate")
65
65
  expect(valid).to eq(false)
66
66
  expect(cert).to be_a OpenSSL::X509::Certificate
67
67
  end
68
68
 
69
- it "returns error on incomplete chain", :retry => 5 do
69
+ it "returns error on incomplete chain" do
70
70
  valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
71
71
  expect(error).to eq ("error code 20: unable to get local issuer certificate")
72
72
  expect(valid).to eq(false)
73
73
  expect(cert).to be_a OpenSSL::X509::Certificate
74
74
  end
75
75
 
76
- it "returns error on untrusted root", :retry => 5 do
76
+ it "returns error on untrusted root" do
77
77
  valid, error, cert = SSLTest.test("https://untrusted-root.testserver.host/")
78
78
  expect(error).to eq ("error code 19: self-signed certificate in certificate chain")
79
79
  expect(valid).to eq(false)
80
80
  expect(cert).to be_a OpenSSL::X509::Certificate
81
81
  end
82
82
 
83
- it "returns error on invalid host", :retry => 5 do
83
+ it "returns error on invalid host" do
84
84
  valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
85
85
  expect(error).to include('error code 62: hostname mismatch')
86
86
  expect(valid).to eq(false)
87
87
  expect(cert).to be_a OpenSSL::X509::Certificate
88
88
  end
89
89
 
90
- it "returns error on expired cert", :retry => 5 do
90
+ it "returns error on expired cert" do
91
91
  valid, error, cert = SSLTest.test("https://expired-rsa-dv.ssl.com/")
92
92
  expect(error).to eq ("error code 10: certificate has expired")
93
93
  expect(valid).to eq(false)
@@ -128,10 +128,12 @@ describe SSLTest do
128
128
  expect(cert).to be_a OpenSSL::X509::Certificate
129
129
  end
130
130
 
131
- it "returns error on revoked cert (CRL)", :retry => 5 do
131
+ it "returns error on revoked cert (CRL)" do
132
132
  # CRL is tried first and detects the revocation, so OCSP is never used
133
133
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
134
134
  expect(SSLTest).not_to receive(:test_ocsp_revocation)
135
+ # On a serial byte-match we fall back to #revoked to extract the reason/date
136
+ expect_any_instance_of(OpenSSL::X509::CRL).to receive(:revoked).and_call_original
135
137
  valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
136
138
  expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
137
139
  expect(valid).to eq(false)
@@ -161,6 +163,9 @@ describe SSLTest do
161
163
  # CRL is tried first and succeeds for both certs, so OCSP is never used
162
164
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
163
165
  expect(SSLTest).not_to receive(:test_ocsp_revocation)
166
+ # Both certs are absent from their CRL, so the serial byte-search short-circuits
167
+ # and we never materialise the (potentially huge) revoked list.
168
+ expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:revoked)
164
169
  valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
165
170
  expect(error).to be_nil
166
171
  expect(valid).to eq(true)
@@ -236,7 +241,7 @@ describe SSLTest do
236
241
  end
237
242
  end
238
243
 
239
- describe '.follow_crl_redirects' do
244
+ describe '.follow_crl_redirects', retry: 5 do # fetches a live CRL
240
245
  before { SSLTest.cache.clear }
241
246
  # 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
242
247
  it "fetch CRL list and updates cache" do
@@ -267,7 +272,7 @@ describe SSLTest do
267
272
  end
268
273
  end
269
274
 
270
- describe '.cache' do
275
+ describe '.cache', retry: 5 do # some examples hit live CRL/OCSP endpoints
271
276
  # Restore the default in-process store after tests that swap the backend so
272
277
  # global state doesn't leak between examples.
273
278
  after { SSLTest.cache = SSLTest::MemoryStore.new }
@@ -294,7 +299,7 @@ describe SSLTest do
294
299
  end
295
300
  end
296
301
 
297
- describe '.test_cert' do
302
+ describe '.test_cert', retry: 5 do # revocation checks hit live CRL/OCSP endpoints
298
303
  it "returns no error on valid SNI website" do
299
304
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_mycs_com_client.pem')))
300
305
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_mycs_com_ca_bundle.pem')))
@@ -488,4 +493,51 @@ describe SSLTest do
488
493
  end
489
494
  end
490
495
  end
496
+
497
+ describe '.revocation_order' do # no network: dispatch logic is stubbed
498
+ after { SSLTest.revocation_order = %i[crl ocsp] } # reset to the default
499
+
500
+ let(:cert) { OpenSSL::X509::Certificate.new }
501
+ let(:issuer) { OpenSSL::X509::Certificate.new }
502
+ let(:chain) { [cert, issuer] }
503
+
504
+ it "defaults to CRL first" do
505
+ expect(SSLTest.revocation_order).to eq(%i[crl ocsp])
506
+ end
507
+
508
+ it "validates the value" do
509
+ expect { SSLTest.revocation_order = %i[crl] }.to raise_error(ArgumentError)
510
+ expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
511
+ end
512
+
513
+ it "checks CRL first by default, falling back to OCSP on error" do
514
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return([false, "CRL boom", nil])
515
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return(:ocsp_ok)
516
+ result = SSLTest.send(:test_chain_revocation, chain)
517
+ expect(result).to eq([false, nil, nil])
518
+ end
519
+
520
+ it "checks OCSP first when configured, falling back to CRL on error" do
521
+ SSLTest.revocation_order = %i[ocsp crl]
522
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return([false, "OCSP boom", nil])
523
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return(:crl_ok)
524
+ result = SSLTest.send(:test_chain_revocation, chain)
525
+ expect(result).to eq([false, nil, nil])
526
+ end
527
+
528
+ it "does not try the second method when the first one passes" do
529
+ SSLTest.revocation_order = %i[ocsp crl]
530
+ expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
531
+ expect(SSLTest).not_to receive(:test_crl_revocation)
532
+ expect(SSLTest.send(:test_chain_revocation, chain)).to eq([false, nil, nil])
533
+ end
534
+
535
+ it "combines error messages in the configured order when all methods fail" do
536
+ SSLTest.revocation_order = %i[ocsp crl]
537
+ allow(SSLTest).to receive(:test_ocsp_revocation).and_return([false, "OCSP boom", nil])
538
+ allow(SSLTest).to receive(:test_crl_revocation).and_return([false, "CRL boom", nil])
539
+ _revoked, message, _date = SSLTest.send(:test_chain_revocation, chain)
540
+ expect(message).to eq("OCSP: OCSP boom, CRL: CRL boom")
541
+ end
542
+ end
491
543
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ssl-test
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrien Rey-Jarthon
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-17 00:00:00.000000000 Z
10
+ date: 2026-06-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler