ssl-test 2.0.0 → 2.1.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: 48563a079839f759ca0f765c768fa7280af962036d2e1e9acd81aaa2dd8967fb
4
+ data.tar.gz: 2a51edf744b6116222f52ea1b72b502805f2a2c78beab94620b408a8c7b05d07
5
5
  SHA512:
6
- metadata.gz: 895f1193c0924d93b0bc4c29dea5d9de8b38b047f25c83273d13146c3bffa0a201676e320d6f4a6f373f231f87fdf0fda7bbef5a8983e40d489c8179097ed8dc
7
- data.tar.gz: c1d2d3cd0014d31e01456dd5f0d1e58a59e820bb77f8e15a2755a27421a107b63d6cc653f115a7e50f19b475f40b1961bbdccab3b92181f1a5f812f68a668604
6
+ metadata.gz: bd2cb2d7de4d133df831932a4d8a44974fc49dd592765fa7669f9d272c1e169d8ea90c728109824dafc89d20a11d83da135f9a7852466781bc58890dcc3a322a
7
+ data.tar.gz: b96a5c2515c952f25ebe823c88d5daf2e8bcc60024d76a7ea715fc854b199bb4e1e06f4d3f787490a6f9bfb987eaba3f4be5692a063a74b4f55fa605e3e8c7e4
@@ -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
@@ -59,7 +59,7 @@ error # => nil
59
59
  cert # => #<OpenSSL::X509::Certificate...>
60
60
  ```
61
61
 
62
- Revoked certificates are detected using [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) by default:
62
+ Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) by default:
63
63
 
64
64
  ```ruby
65
65
  valid, error, cert = SSLTest.test_url "https://revoked.badssl.com"
@@ -68,7 +68,14 @@ error # => "SSL certificate revoked: Key Compromise (revocation date: 2019-10-07
68
68
  cert # => #<OpenSSL::X509::Certificate...>
69
69
  ```
70
70
 
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).
71
+ If the OCSP endpoint is missing, invalid or unreachable the certificate revocation will be tested using the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list).
72
+
73
+ You can swap the order if you'd rather check CRL first and only fall back to OCSP on error (the default is OCSP first, since 2.1, because CRLs can be large and checking them is significantly more memory- and CPU-intensive):
74
+
75
+ ```ruby
76
+ SSLTest.revocation_order = %i[crl ocsp] # CRL first, OCSP fallback
77
+ SSLTest.revocation_order = %i[ocsp crl] # the default: OCSP first, CRL fallback
78
+ ```
72
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
 
@@ -96,7 +103,7 @@ This check will pass for self-signed certificates if the certificate is signed b
96
103
 
97
104
  SSLTester connects as an HTTPS client (without issuing any requests) and then closes the connection. It does so using ruby `net/https` library and verifies the SSL status. It also hooks into the validation process to intercept the raw certificate for you.
98
105
 
99
- After that it fetches the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) to verify if the certificate has been revoked. If the CRL is not available it'll query the [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint instead. It does this for every certificates in the chain (except the root which is trusted by your Operating System). It is possible the first one will be validated with CRL and the intermediate with OCSP depending on what they offer.
106
+ After that it queries the [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint to verify if the certificate has been revoked. If the OCSP endpoint is not available it'll fetch the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) instead. It does this for every certificates in the chain (except the root which is trusted by your Operating System). It is possible the first one will be validated with OCSP and the intermediate with CRL depending on what they offer.
100
107
 
101
108
  ### Caching
102
109
 
@@ -193,9 +200,10 @@ But also **revoked certs** like most browsers (not handled by `curl`)
193
200
 
194
201
  ## Changelog
195
202
 
196
- See also github releases: https://github.com/jarthod/ssl-test/releases
197
-
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)
203
+ * 2.1.1 - 2026-06-21: Refactor CRL code to use newer OpenSSL `crl_uris` helper
204
+ * 2.1.0 - 2026-06-20: Check revocation with OCSP first and fall back to CRL (was CRL first since 1.6) because CRLs can be large and checking them is significantly more memory- and CPU-intensive. Set `SSLTest.revocation_order = %i[crl ocsp]` to restore the previous order.
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
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Needs the patched openssl that defines #by_serial; point OPENSSL_LIB at the dev
4
+ # build (defaults to ../openssl/lib). gem install benchmark-ips benchmark-memory
5
+ #
6
+ # ruby by_serial_bench.rb [CRL_URL]
7
+
8
+ dev = ENV["OPENSSL_LIB"] || File.expand_path("../openssl/lib", __dir__)
9
+ $LOAD_PATH.unshift(dev) if File.exist?(File.join(dev, "openssl.so"))
10
+
11
+ require "openssl"
12
+ require "net/http"
13
+ require "uri"
14
+ require "tmpdir"
15
+ require "benchmark/ips"
16
+ require "benchmark/memory"
17
+
18
+ abort "loaded openssl has no #by_serial (set OPENSSL_LIB to the patched build)" unless
19
+ OpenSSL::X509::CRL.method_defined?(:by_serial)
20
+
21
+ url = ARGV[0] || "http://c.cf-i.ssl.com/ae801ed1c55bb579d79208b0d772acfb8cc3a208.crl" # big CRL example
22
+ cache = File.join(Dir.tmpdir, "by_serial_bench_#{File.basename(URI(url).path)}")
23
+ body = File.exist?(cache) ? File.binread(cache) :
24
+ (warn("downloading #{url} ..."); d = Net::HTTP.get(URI(url)); File.binwrite(cache, d); d)
25
+
26
+ crl = OpenSSL::X509::CRL.new(body)
27
+ entries = crl.revoked.size
28
+ absent = 0xDEAD_BEEF_CAFE_F00D
29
+ present = crl.revoked[entries / 2].serial # middle of the list for median performance
30
+
31
+ puts "#{OpenSSL::OPENSSL_LIBRARY_VERSION} — #{url}"
32
+ puts "#{(body.bytesize / 1e6).round(1)} MB DER, #{entries} revoked entries"
33
+
34
+ [["not revoked", absent], ["revoked", present]].each do |label, serial|
35
+ abort "mismatch for #{label}" unless
36
+ crl.by_serial(serial) == crl.revoked.find { |r| r.serial == serial }
37
+ puts "\n=== #{label} serial ==="
38
+
39
+ puts "\n-- warm: lookup on a parsed CRL --"
40
+ @list = nil
41
+ Benchmark.ips do |x|
42
+ x.config(warmup: 1, time: 3)
43
+ x.report("revoked.find") { (@list ||= crl.revoked).find { |r| r.serial == serial } }
44
+ x.report("by_serial") { crl.by_serial(serial) }
45
+ x.compare!
46
+ end
47
+ Benchmark.memory do |x|
48
+ x.report("revoked.find") { crl.revoked.find { |r| r.serial == serial } }
49
+ x.report("by_serial") { crl.by_serial(serial) }
50
+ x.compare!
51
+ end
52
+
53
+ puts "\n-- cold: parse a fresh CRL + one lookup --"
54
+ Benchmark.ips do |x|
55
+ x.config(warmup: 0, time: 3)
56
+ x.report("revoked.find") { OpenSSL::X509::CRL.new(body).revoked.find { |r| r.serial == serial } }
57
+ x.report("by_serial") { OpenSSL::X509::CRL.new(body).by_serial(serial) }
58
+ x.compare!
59
+ end
60
+ Benchmark.memory do |x|
61
+ x.report("revoked.find") { OpenSSL::X509::CRL.new(body).revoked.find { |r| r.serial == serial } }
62
+ x.report("by_serial") { OpenSSL::X509::CRL.new(body).by_serial(serial) }
63
+ x.compare!
64
+ end
65
+ end
data/crl_bench.rb ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # Benchmark: checking whether a single serial is revoked in a CRL.
5
+ # A) OpenSSL::X509::CRL#revoked.find { ... } (only API available today)
6
+ # B) X509_CRL_get0_by_serial() (proposed, via Fiddle)
7
+ #
8
+ # Each approach runs in its own forked process so the reported peak RSS reflects
9
+ # only that approach. Requires MRI on Linux (uses fork + /proc; falls back to
10
+ # Process.getrusage on macOS/BSD).
11
+ #
12
+ # ruby crl_bench.rb [CRL_URL]
13
+
14
+ require "openssl"
15
+ require "fiddle"
16
+ require "benchmark"
17
+ require "net/http"
18
+ require "uri"
19
+ require "tmpdir"
20
+
21
+ CRL_URL = ARGV[0] || "http://c.cf-i.ssl.com/ae801ed1c55bb579d79208b0d772acfb8cc3a208.crl"
22
+
23
+ # --- fetch the CRL (cached in /tmp so reruns are offline) ----------------------
24
+ cache = File.join(Dir.tmpdir, "crl_bench_#{File.basename(URI(CRL_URL).path)}")
25
+ body = if File.exist?(cache)
26
+ File.binread(cache)
27
+ else
28
+ warn "downloading #{CRL_URL} ..."
29
+ data = Net::HTTP.get_response(URI(CRL_URL)).body
30
+ File.binwrite(cache, data)
31
+ data
32
+ end
33
+
34
+ # --- libcrypto bindings (symbols are already in-process via require "openssl") -
35
+ def sym(name)
36
+ Fiddle::Handle::DEFAULT[name]
37
+ rescue Fiddle::DLError
38
+ @lib ||= %w[libcrypto.so libcrypto.so.3 libcrypto.dylib libcrypto.so.1.1]
39
+ .lazy.map { |n| begin; Fiddle.dlopen(n); rescue Fiddle::DLError; nil; end }.find(&:itself)
40
+ @lib[name]
41
+ end
42
+
43
+ P, L, I = Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_INT
44
+ D2I_CRL = Fiddle::Function.new(sym("d2i_X509_CRL"), [P, P, L], P)
45
+ D2I_INT = Fiddle::Function.new(sym("d2i_ASN1_INTEGER"), [P, P, L], P)
46
+ GET0 = Fiddle::Function.new(sym("X509_CRL_get0_by_serial"),[P, P, P], I)
47
+
48
+ # d2i_*(a, **pp, len) advances *pp; wrap a DER String into a C object pointer.
49
+ def d2i(fn, der)
50
+ buf = Fiddle::Pointer[der]
51
+ obj = fn.call(nil, Fiddle::Pointer[[buf.to_i].pack("q")], der.bytesize)
52
+ raise "d2i failed" if obj.null?
53
+ obj
54
+ end
55
+
56
+ # Peak resident set size in MB (high-water mark for this process).
57
+ def peak_mb
58
+ if File.exist?("/proc/self/status") # Linux
59
+ File.foreach("/proc/self/status") { |l| return l.split[1].to_f / 1024 if l.start_with?("VmHWM:") }
60
+ end
61
+ if Process.respond_to?(:getrusage) # macOS / BSD
62
+ m = Process.getrusage(:SELF).maxrss
63
+ return RUBY_PLATFORM.include?("darwin") ? m / 1024.0 / 1024 : m / 1024.0
64
+ end
65
+ 0.0
66
+ end
67
+
68
+ # Run the block in a fresh process; peak RSS then reflects only that work.
69
+ def isolate
70
+ rd, wr = IO.pipe
71
+ pid = fork { rd.close; wr.write(Marshal.dump(yield)); wr.close; exit! }
72
+ wr.close
73
+ out = rd.read; rd.close; Process.wait(pid)
74
+ Marshal.load(out)
75
+ end
76
+
77
+ # Approach A: the only thing the gem can do today.
78
+ def bench_revoked(body, serial)
79
+ bn = OpenSSL::BN.new(serial)
80
+ crl = nil; tp = Benchmark.realtime { crl = OpenSSL::X509::CRL.new(body) }
81
+ hit = nil; entries = nil
82
+ tl = Benchmark.realtime { list = crl.revoked; entries = list.size; hit = list.find { |r| r.serial == bn } }
83
+ { entries: entries, parse_ms: tp * 1000, lookup_ms: tl * 1000, rss_mb: peak_mb, revoked: !hit.nil? }
84
+ end
85
+
86
+ # Approach B: X509_CRL_get0_by_serial (0 = not found, 1/2 = found).
87
+ def bench_get0(body, serial)
88
+ crl = nil; tp = Benchmark.realtime { crl = d2i(D2I_CRL, body) }
89
+ rc = nil
90
+ tl = Benchmark.realtime do
91
+ asn1 = d2i(D2I_INT, OpenSSL::ASN1::Integer.new(serial).to_der)
92
+ rc = GET0.call(crl, Fiddle::NULL, asn1)
93
+ end
94
+ { parse_ms: tp * 1000, lookup_ms: tl * 1000, rss_mb: peak_mb, revoked: rc != 0 }
95
+ end
96
+
97
+ absent = 0xDEAD_BEEF_CAFE_F00D # ~certainly not in the CRL
98
+ present = isolate { OpenSSL::X509::CRL.new(body).revoked.first&.serial&.to_i } # a real revoked serial
99
+
100
+ puts "CRL: #{CRL_URL}"
101
+ puts "DER: #{body.bytesize} bytes (#{(body.bytesize / 1e6).round(1)} MB)"
102
+ puts "Ruby #{RUBY_VERSION}, openssl gem #{OpenSSL::VERSION}, #{OpenSSL::OPENSSL_LIBRARY_VERSION}"
103
+ puts
104
+
105
+ a = isolate { bench_revoked(body, absent) }
106
+ b = isolate { bench_get0(body, absent) }
107
+
108
+ puts "Lookup of an absent serial (#{a[:entries]} entries in the CRL):"
109
+ printf " %-26s parse %7.1f ms | lookup %8.1f ms | peak RSS %7.1f MB\n",
110
+ "A #revoked.find", a[:parse_ms], a[:lookup_ms], a[:rss_mb]
111
+ printf " %-26s parse %7.1f ms | lookup %8.1f ms | peak RSS %7.1f MB\n",
112
+ "B get0_by_serial", b[:parse_ms], b[:lookup_ms], b[:rss_mb]
113
+ printf " => %.1fx faster lookup, %.1fx less peak RSS\n",
114
+ a[:lookup_ms] / b[:lookup_ms], a[:rss_mb] / b[:rss_mb]
115
+ puts
116
+
117
+ # correctness: both must agree on an absent and a present serial
118
+ ok_absent = (a[:revoked] == false) && (b[:revoked] == false)
119
+ ok_present = present && isolate { bench_revoked(body, present)[:revoked] } &&
120
+ isolate { bench_get0(body, present)[:revoked] }
121
+ puts "correctness: absent serial -> both 'not revoked' (#{ok_absent ? 'PASS' : 'FAIL'}); " \
122
+ "present serial -> both 'revoked' (#{ok_present ? 'PASS' : 'FAIL'})"
data/lib/ssl-test/crl.rb CHANGED
@@ -12,46 +12,41 @@ module SSLTest
12
12
  # A note about caching:
13
13
  # I choose to only cache the raw HTTP body here (and not the parsed list or better a hash
14
14
  # indexed by certificat serial). This is not CPU efficient because it means every time we
15
- # need to check a cert from a cached CRL we need to parse it again, instantiate the list
15
+ # need to check a cert from a cached CRL we need to read it again, optionally instantiate the list
16
16
  # of Revoked certs and then iterate to find it (there's no API to find one cert without
17
- # generting the list unfortuantely).
17
+ # generting the list yet: https://github.com/ruby/openssl/pull/1065).
18
18
  # I did this because of memory efficiency, because for big 20MB CRL list (so taking 20MB
19
19
  # in memory cache), the parsed version takes more than 100M, the list of Revoked certs 120MB,
20
20
  # and building a hash with serial, time and reason takes even more.
21
21
  # So doing this would be MUCH faster in terms of CPU for subsequent tests on the same CRL
22
22
  # but would take a LOT of memory.
23
- # Note: we now check CRL first for every cert in the chain (leaf included), so leaf
24
- # CRLs are fetched and cached too. These can be large for busy CAs, which makes the
25
- # memory tradeoff above (caching the raw body rather than the parsed list) even more relevant.
26
23
 
27
24
  private
28
25
 
29
26
  def test_crl_revocation cert, issuer:, chain:, **options
30
- crl_distribution_points = cert.extensions.find do |extension|
31
- extension.oid == "crlDistributionPoints"
32
- end
33
-
34
- return [false, "Missing crlDistributionPoints extension", nil] unless crl_distribution_points
35
-
36
- # OpenSSL 2.2+ may simplify this: https://github.com/ruby/openssl/commit/ea702a106d3d8136c48f244593de95666be0edf9
37
- crl = crl_distribution_points.value.split("\n").find do |description|
38
- description.match?(/URI:/)
39
- end
27
+ crl = cert.crl_uris&.first
28
+ return [false, "Missing crlDistributionPoints extension", nil] if crl.nil?
40
29
 
41
- return [false, "Missing CRL URI in crlDistributionPoints extension", nil] unless crl
42
-
43
- crl_uri = URI(crl[/URI:(.*)/, 1])
30
+ crl_uri = URI(crl)
44
31
  http_response, crl_request_error = follow_crl_redirects(crl_uri, **options)
45
32
  return [false, "Request failed (URI: #{crl_uri}): #{crl_request_error}", nil] unless http_response
46
33
 
47
34
  response = OpenSSL::X509::CRL.new http_response
48
35
  return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)
49
36
 
37
+ # Fast path: scan the raw response for the cert's serial encoded as DER.
38
+ # In most case (not revoked) this lets us skip response.revoked, which
39
+ # instantiate the *entire* revocation list as Ruby objects (>1M objects for busy CAs)
40
+ serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
41
+ return :crl_ok unless response.to_der.include?(serial_der)
42
+
43
+ # The serial's bytes appear (a real hit, or a rare collision):
44
+ # confirm authoritatively and pull the reason/date. The costly revoked-list
45
+ # materialisation only happens here, i.e. for actually-revoked certs.
50
46
  revoked = response.revoked.find { |r| r.serial == cert.serial }
51
47
  if revoked
52
48
  reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
53
49
  return [true, reason || "Unknown reason", revoked.time]
54
- else
55
50
  end
56
51
 
57
52
  :crl_ok
@@ -77,12 +72,13 @@ module SSLTest
77
72
  http.read_timeout = read_timeout
78
73
 
79
74
  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
75
+ # Include conditional caching headers from cache to save bandwidth if the
76
+ # list didn't change (304). Send both validators when present: some
77
+ # CDN-backed CAs (e.g. DigiCert) serve per-node ETags they won't honor via
78
+ # If-None-Match but will revalidate via If-Modified-Since, so sending only
79
+ # the ETag defeats the 304 and re-downloads the whole list every time.
80
+ req["If-None-Match"] = cache_entry[:etag] if cache_entry&.[](:etag)
81
+ req["If-Modified-Since"] = cache_entry[:last_mod] if cache_entry&.[](:last_mod)
86
82
  http_response = http.request(req)
87
83
  case http_response
88
84
  when Net::HTTPNotModified
@@ -93,7 +89,7 @@ module SSLTest
93
89
  when Net::HTTPSuccess
94
90
  # Success, update (or add to) cache and return frech body
95
91
  @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
92
+ @logger&.warn { "SSLTest + CRL: Warning: massive file size (#{http_response.body.bytesize} bytes)" } if http_response.body.bytesize > 1024**2 # 1MB
97
93
  @logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
98
94
  cache.write(cache_key, {
99
95
  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.1.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,24 @@ 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 OCSP first, since CRLs can be large and
121
+ # checking them is significantly more memory- and CPU-intensive. Set to
122
+ # %i[crl ocsp] to check CRL first (e.g. to reduce revocation propagation delay).
123
+ def revocation_order
124
+ @revocation_order ||= %i[ocsp crl]
125
+ end
126
+
127
+ def revocation_order= order
128
+ order = Array(order).map { |m| m.to_sym }
129
+ unless order.sort == %i[crl ocsp]
130
+ raise ArgumentError, "SSLTest.revocation_order must be %i[crl ocsp] or %i[ocsp crl], got #{order.inspect}"
131
+ end
132
+ @revocation_order = order
133
+ end
134
+
117
135
  private
118
136
 
119
137
  def revocation_message(revoked, revocation_date, message)
@@ -147,26 +165,37 @@ module SSLTest
147
165
  chain[0..-2].each_with_index do |cert, i|
148
166
  @logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
149
167
 
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
168
+ # Try each revocation method in the configured order, falling back to the
169
+ # next one only when the current method errors out.
170
+ errors = {}
171
+ passed = false
172
+ revocation_order.each do |method|
173
+ result = test_revocation(method, cert, issuer: chain[i + 1], chain: chain, **options)
174
+ @logger&.debug { "SSLTest + #{method.to_s.upcase}: #{result}" }
175
+ if result == :"#{method}_ok" # passed, go to next cert
176
+ passed = true
177
+ break
178
+ end
179
+ return result if result[0] == true # revoked
180
+ errors[method] = result[1] # errored, try the next method
181
+ end
182
+ next if passed
161
183
 
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]
184
+ # If all methods failed, return a soft fail with a combination of the error messages
185
+ return [false, errors.map { |method, message| "#{method.to_s.upcase}: #{message}" }.join(", "), nil]
164
186
  end
165
187
 
166
188
  # If all test passed, the certificate is not revoked
167
189
  [false, nil, nil]
168
190
  end
169
191
 
192
+ def test_revocation method, cert, **options
193
+ case method
194
+ when :crl then test_crl_revocation(cert, **options)
195
+ when :ocsp then test_ocsp_revocation(cert, **options)
196
+ end
197
+ end
198
+
170
199
  def cert_field_to_hash field
171
200
  field.to_a.each.with_object({}) do |v, h|
172
201
  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
@@ -69,7 +70,9 @@ describe "SSLTest.cache.size" do
69
70
  end
70
71
 
71
72
  it "returns OCSP cache size properly" do
72
- SSLTest.test("https://github.com")
73
+ # Google's leaf is checked via OCSP and its intermediate (no OCSP URI) via CRL,
74
+ # so this populates both the OCSP and CRL caches under the default OCSP-first order.
75
+ SSLTest.test("https://google.com")
73
76
  expect(SSLTest.cache.size[:ocsp][:responses]).to eq(1)
74
77
  expect(SSLTest.cache.size[:ocsp][:errors]).to eq(0)
75
78
  expect(SSLTest.cache.size[:ocsp][:bytes]).to be > 0
@@ -12,9 +12,10 @@ 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
+ config.display_try_failure_messages = true
18
19
  config.default_sleep_interval = 1
19
20
  end
20
21
 
@@ -26,7 +27,7 @@ describe SSLTest do
26
27
 
27
28
  after(:each) { proxy_thread&.kill }
28
29
 
29
- describe '.test_url' do
30
+ describe '.test_url', retry: 5 do # examples hit live TLS/CRL/OCSP endpoints
30
31
  it "returns no error on valid SNI website" do
31
32
  valid, error, cert = SSLTest.test("https://www.mycs.com")
32
33
  expect(error).to be_nil
@@ -59,35 +60,35 @@ describe SSLTest do
59
60
  expect(cert).to be_a OpenSSL::X509::Certificate
60
61
  end
61
62
 
62
- it "returns error on self signed certificate", :retry => 5 do
63
+ it "returns error on self signed certificate" do
63
64
  valid, error, cert = SSLTest.test("https://self-signed.testserver.host/")
64
65
  expect(error).to eq ("error code 18: self-signed certificate")
65
66
  expect(valid).to eq(false)
66
67
  expect(cert).to be_a OpenSSL::X509::Certificate
67
68
  end
68
69
 
69
- it "returns error on incomplete chain", :retry => 5 do
70
+ it "returns error on incomplete chain" do
70
71
  valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
71
72
  expect(error).to eq ("error code 20: unable to get local issuer certificate")
72
73
  expect(valid).to eq(false)
73
74
  expect(cert).to be_a OpenSSL::X509::Certificate
74
75
  end
75
76
 
76
- it "returns error on untrusted root", :retry => 5 do
77
+ it "returns error on untrusted root" do
77
78
  valid, error, cert = SSLTest.test("https://untrusted-root.testserver.host/")
78
79
  expect(error).to eq ("error code 19: self-signed certificate in certificate chain")
79
80
  expect(valid).to eq(false)
80
81
  expect(cert).to be_a OpenSSL::X509::Certificate
81
82
  end
82
83
 
83
- it "returns error on invalid host", :retry => 5 do
84
- valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
85
- expect(error).to include('error code 62: hostname mismatch')
84
+ it "returns error on invalid host" do
85
+ valid, error, cert = SSLTest.test("https://db3.updn.io/")
86
+ expect(error).to eq('error code 62: hostname mismatch')
86
87
  expect(valid).to eq(false)
87
88
  expect(cert).to be_a OpenSSL::X509::Certificate
88
89
  end
89
90
 
90
- it "returns error on expired cert", :retry => 5 do
91
+ it "returns error on expired cert" do
91
92
  valid, error, cert = SSLTest.test("https://expired-rsa-dv.ssl.com/")
92
93
  expect(error).to eq ("error code 10: certificate has expired")
93
94
  expect(valid).to eq(false)
@@ -111,7 +112,7 @@ describe SSLTest do
111
112
  end
112
113
 
113
114
  it "reports revocation exceptions" do
114
- expect(SSLTest).to receive(:follow_crl_redirects).and_raise(ArgumentError.new("test"))
115
+ expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
115
116
  valid, error, cert = SSLTest.test("https://digicert.com")
116
117
  expect(error).to eq ("SSL certificate test failed: test")
117
118
  expect(valid).to be_nil
@@ -119,48 +120,52 @@ describe SSLTest do
119
120
  end
120
121
 
121
122
  it "returns error on revoked cert (OCSP)" do
122
- # CRL is tried first; disable it so OCSP performs the revocation check
123
- expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
123
+ # OCSP is tried first and detects the revocation, so CRL is never used
124
124
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
125
+ expect(SSLTest).not_to receive(:follow_crl_redirects)
125
126
  valid, error, cert = SSLTest.test("https://revoked-rsa-dv.ssl.com/")
126
127
  expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unspecified reason (revocation date: 2026-06-09 14:37:38 UTC)")
127
128
  expect(valid).to eq(false)
128
129
  expect(cert).to be_a OpenSSL::X509::Certificate
129
130
  end
130
131
 
131
- it "returns error on revoked cert (CRL)", :retry => 5 do
132
- # CRL is tried first and detects the revocation, so OCSP is never used
132
+ it "returns error on revoked cert (CRL)" do
133
+ # Disable OCSP (tried first) so the CRL performs the revocation check
134
+ expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
133
135
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
134
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
135
- valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
136
- expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
136
+ # On a serial byte-match we fall back to #revoked to extract the reason/date
137
+ expect_any_instance_of(OpenSSL::X509::CRL).to receive(:revoked).and_call_original
138
+ valid, error, cert = SSLTest.test("https://revoked.testserver.host/")
139
+ expect(error).to match(/SSL certificate revoked: Unknown reason \(revocation date:/)
137
140
  expect(valid).to eq(false)
138
141
  expect(cert).to be_a OpenSSL::X509::Certificate
139
142
  end
140
143
 
141
144
  it "stops following redirection after the limit for the revoked certs check" do
142
145
  valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
143
- expect(error).to include("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension")
144
- expect(error).to include("OCSP: Request failed")
146
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
145
147
  expect(error).to include("Too many redirections (> 0)")
146
148
  expect(valid).to eq(true)
147
149
  expect(cert).to be_a OpenSSL::X509::Certificate
148
150
  end
149
151
 
150
152
  it "warns when the OCSP URI is missing" do
151
- # Disable CRL (tried first) to see the OCSP error message
152
- expect(SSLTest).to receive(:test_crl_revocation).twice.and_return([false, "skip CRL", nil])
153
+ # Disable CRL fallback to see the OCSP error message
154
+ expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
153
155
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
154
156
  valid, error, cert = SSLTest.test("https://google.com")
155
- expect(error).to eq ("Revocation test couldn't be performed: CRL: skip CRL, OCSP: Missing OCSP URI in authorityInfoAccess extension")
157
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
156
158
  expect(valid).to eq(true)
157
159
  expect(cert).to be_a OpenSSL::X509::Certificate
158
160
  end
159
161
 
160
162
  it "works with CRL only" do
161
- # CRL is tried first and succeeds for both certs, so OCSP is never used
163
+ # Disable OCSP so the CRL performs the revocation check for both certs
164
+ expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
162
165
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
163
- 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)
@@ -172,15 +177,15 @@ describe SSLTest do
172
177
  expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
173
178
  expect(SSLTest).not_to receive(:follow_crl_redirects)
174
179
  valid, error, cert = SSLTest.test("https://github.com")
175
- expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension, OCSP: skip OCSP")
180
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
176
181
  expect(valid).to eq(true)
177
182
  expect(cert).to be_a OpenSSL::X509::Certificate
178
183
  end
179
184
 
180
- it "works with OCSP for first cert and CRL for intermediate (GitHub)" do
185
+ it "works with OCSP for first cert and CRL for intermediate (Google)" do
181
186
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
182
187
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
183
- valid, error, cert = SSLTest.test("https://github.com")
188
+ valid, error, cert = SSLTest.test("https://google.com")
184
189
  expect(error).to be_nil
185
190
  expect(valid).to eq(true)
186
191
  expect(cert).to be_a OpenSSL::X509::Certificate
@@ -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')))
@@ -337,7 +342,7 @@ describe SSLTest do
337
342
  it "reports revocation exceptions" do
338
343
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/digicert_com_client.pem')))
339
344
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/digicert_com_ca_bundle.pem')))
340
- expect(SSLTest).to receive(:follow_crl_redirects).and_raise(ArgumentError.new("test"))
345
+ expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
341
346
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
342
347
  expect(error).to eq("SSL certificate test failed: test")
343
348
  expect(valid).to be_nil
@@ -348,9 +353,9 @@ describe SSLTest do
348
353
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_client.pem')))
349
354
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_ca_bundle.pem')))
350
355
 
351
- # CRL is tried first; disable it so OCSP performs the revocation check
352
- expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
356
+ # OCSP is tried first and detects the revocation, so CRL is never used
353
357
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
358
+ expect(SSLTest).not_to receive(:follow_crl_redirects)
354
359
 
355
360
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
356
361
  expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2025-06-09 15:07:39 UTC)")
@@ -362,9 +367,9 @@ describe SSLTest do
362
367
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_badssl_client.pem')))
363
368
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_badssl_ca_bundle.pem')))
364
369
 
365
- # CRL is tried first and detects the revocation, so OCSP is never used
370
+ # Disable OCSP (tried first) so the CRL performs the revocation check
371
+ expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
366
372
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
367
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
368
373
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
369
374
  expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
370
375
  expect(valid).to eq(false)
@@ -376,8 +381,7 @@ describe SSLTest do
376
381
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
377
382
 
378
383
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle, redirection_limit: 0)
379
- expect(error).to include("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension")
380
- expect(error).to include("OCSP: Request failed")
384
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
381
385
  expect(error).to include("Too many redirections (> 0)")
382
386
  expect(valid).to eq(true)
383
387
  expect(cert).to eq(cert)
@@ -387,12 +391,12 @@ describe SSLTest do
387
391
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
388
392
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
389
393
 
390
- # Disable CRL (tried first) to see the OCSP error message
391
- expect(SSLTest).to receive(:test_crl_revocation).twice.and_return([false, "skip CRL", nil])
394
+ # Disable CRL fallback to see the OCSP error message
395
+ expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
392
396
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
393
397
 
394
398
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
395
- expect(error).to eq ("Revocation test couldn't be performed: CRL: skip CRL, OCSP: Missing OCSP URI in authorityInfoAccess extension")
399
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
396
400
  expect(valid).to eq(true)
397
401
  expect(cert).to eq(cert)
398
402
  end
@@ -401,9 +405,9 @@ describe SSLTest do
401
405
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_client.pem')))
402
406
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_ca_bundle.pem')))
403
407
 
404
- # CRL is tried first and succeeds for both certs, so OCSP is never used
408
+ # Disable OCSP so the CRL performs the revocation check for both certs
409
+ expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
405
410
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
406
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
407
411
 
408
412
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
409
413
  expect(error).to be_nil
@@ -420,18 +424,18 @@ describe SSLTest do
420
424
  expect(SSLTest).not_to receive(:follow_crl_redirects)
421
425
 
422
426
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
423
- expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension, OCSP: skip OCSP")
427
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
424
428
  expect(valid).to eq(true)
425
429
  expect(cert).to eq(cert)
426
430
 
427
431
  end
428
432
 
429
- it "works with OCSP for first cert and CRL for intermediate (GitHub)" do
433
+ it "works with OCSP for first cert and CRL for intermediate (Google)" do
430
434
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
431
435
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
432
436
 
433
- cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_github_com_client.pem')))
434
- ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
437
+ cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
438
+ ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
435
439
 
436
440
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
437
441
  expect(error).to be_nil
@@ -470,8 +474,7 @@ describe SSLTest do
470
474
  expect(valid).to eq(true)
471
475
  expect(cert).to eq(cert)
472
476
 
473
- # CRL is tried first, so both certs are checked via CRL (GET) through the proxy
474
- expect($proxy).to have_received(:do_GET).twice
477
+ expect($proxy).to have_received(:do_GET).once
475
478
  end
476
479
  end
477
480
 
@@ -488,4 +491,50 @@ describe SSLTest do
488
491
  end
489
492
  end
490
493
  end
494
+
495
+ describe '.revocation_order' do # no network: dispatch logic is stubbed
496
+ after { SSLTest.revocation_order = %i[ocsp crl] } # reset to the default
497
+
498
+ let(:cert) { OpenSSL::X509::Certificate.new }
499
+ let(:issuer) { OpenSSL::X509::Certificate.new }
500
+ let(:chain) { [cert, issuer] }
501
+
502
+ it "defaults to OCSP first" do
503
+ expect(SSLTest.revocation_order).to eq(%i[ocsp crl])
504
+ end
505
+
506
+ it "validates the value" do
507
+ expect { SSLTest.revocation_order = %i[crl] }.to raise_error(ArgumentError)
508
+ expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
509
+ end
510
+
511
+ it "checks OCSP first by default, falling back to CRL on error" do
512
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return([false, "OCSP boom", nil])
513
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return(:crl_ok)
514
+ result = SSLTest.send(:test_chain_revocation, chain)
515
+ expect(result).to eq([false, nil, nil])
516
+ end
517
+
518
+ it "checks CRL first when configured, falling back to OCSP on error" do
519
+ SSLTest.revocation_order = %i[crl ocsp]
520
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return([false, "CRL boom", nil])
521
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return(:ocsp_ok)
522
+ result = SSLTest.send(:test_chain_revocation, chain)
523
+ expect(result).to eq([false, nil, nil])
524
+ end
525
+
526
+ it "does not try the second method when the first one passes" do
527
+ expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
528
+ expect(SSLTest).not_to receive(:test_crl_revocation)
529
+ expect(SSLTest.send(:test_chain_revocation, chain)).to eq([false, nil, nil])
530
+ end
531
+
532
+ it "combines error messages in the configured order when all methods fail" do
533
+ SSLTest.revocation_order = %i[ocsp crl]
534
+ allow(SSLTest).to receive(:test_ocsp_revocation).and_return([false, "OCSP boom", nil])
535
+ allow(SSLTest).to receive(:test_crl_revocation).and_return([false, "CRL boom", nil])
536
+ _revoked, message, _date = SSLTest.send(:test_chain_revocation, chain)
537
+ expect(message).to eq("OCSP: OCSP boom, CRL: CRL boom")
538
+ end
539
+ end
491
540
  end
data/ssl-test.gemspec CHANGED
@@ -12,6 +12,8 @@ Gem::Specification.new do |spec|
12
12
  spec.homepage = "https://github.com/jarthod/ssl-test"
13
13
  spec.license = "MIT"
14
14
 
15
+ spec.required_ruby_version = ">= 3.1"
16
+
15
17
  spec.files = `git ls-files -z`.split("\x0")
16
18
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
19
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
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.1.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-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -134,6 +134,8 @@ files:
134
134
  - LICENSE.txt
135
135
  - README.md
136
136
  - Rakefile
137
+ - by_serial_bench.rb
138
+ - crl_bench.rb
137
139
  - lib/ssl-test.rb
138
140
  - lib/ssl-test/crl.rb
139
141
  - lib/ssl-test/memory_store.rb
@@ -174,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
174
176
  requirements:
175
177
  - - ">="
176
178
  - !ruby/object:Gem::Version
177
- version: '0'
179
+ version: '3.1'
178
180
  required_rubygems_version: !ruby/object:Gem::Requirement
179
181
  requirements:
180
182
  - - ">="