ssl-test 2.0.1 → 2.1.2

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: 073a878a42dbba9b30e7c69ec4b435cd8f2c607e59a6b894099af73ea60526f8
4
- data.tar.gz: 709b2f097b7d8a61922f2cfcee0cb6651046c57854dea0b3dc17c8367b7b856a
3
+ metadata.gz: 36325440b8b451a3a2820ab4c597ed765a9d7787ff613ea36aeea671892a9882
4
+ data.tar.gz: 794733e1186c7a03f9b04823a165637cf7c559554e84b478d7b3fb57fe2e9e0e
5
5
  SHA512:
6
- metadata.gz: 150c1d97a77c0a37c71a180d7cd643f3a4264280f65261e2cb830b184fed00cb60458bf4d27ce1eb52225db506398d2e1480bf69d5a7d6ffc4ee7e5ad288a5b1
7
- data.tar.gz: '08f66249c1b7d951b71283589d957b6bfe4451934ff155af255e4e31a5f77d0aa27bcd1dcc9cee1dcb0b1ddc450618d97c9a445c5067990009b14ad616933128'
6
+ metadata.gz: 682c775642e4dbae339d37afe3c059f2e34511c942d199eb09b3090a73f65840e09c0df904d2790893752cc7ad4db2f2c7cbdf68f17765cbee4015967d665282
7
+ data.tar.gz: 9bf3c4f4d6113d2884fd33041310a22cb5b6610aebb5e44b62a3cffa3396e56e6e7b6091dab907dcbf0fdec6d2ccdb00eac5f60a5018045ace13d334f0b1d581
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,13 +68,13 @@ 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
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):
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
74
 
75
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
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
78
  ```
79
79
 
80
80
  If both CRL and OCSP tests are impossible, the certificate will still be considered valid but with an error message:
@@ -103,7 +103,7 @@ This check will pass for self-signed certificates if the certificate is signed b
103
103
 
104
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.
105
105
 
106
- 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.
107
107
 
108
108
  ### Caching
109
109
 
@@ -200,8 +200,9 @@ But also **revoked certs** like most browsers (not handled by `curl`)
200
200
 
201
201
  ## Changelog
202
202
 
203
- See also github releases: https://github.com/jarthod/ssl-test/releases
204
-
203
+ * 2.1.2 - 2026-06-23: Add support for faster `CRL#by_serial` method I just added to OpenSSL (https://github.com/ruby/openssl/pull/1065)
204
+ * 2.1.1 - 2026-06-21: Refactor CRL code to use newer OpenSSL `crl_uris` helper
205
+ * 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
206
  * 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
207
  * 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)
207
208
  * 1.6.0 - 2026-06-16: Check revocation with CRL first and fall back to OCSP (was OCSP first) to reduce revocation detection delay
@@ -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,35 +12,22 @@ 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
40
-
41
- return [false, "Missing CRL URI in crlDistributionPoints extension", nil] unless crl
27
+ crl = cert.crl_uris&.first
28
+ return [false, "Missing crlDistributionPoints extension", nil] if crl.nil?
42
29
 
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
 
@@ -53,10 +40,18 @@ module SSLTest
53
40
  serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
54
41
  return :crl_ok unless response.to_der.include?(serial_der)
55
42
 
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.
59
- revoked = response.revoked.find { |r| r.serial == cert.serial }
43
+ # The serial's bytes appear (a real hit, or a rare collision): confirm
44
+ # authoritatively and pull the reason/date. This only runs for the few
45
+ # certs that look revoked, so we can afford the lookup here.
46
+ revoked =
47
+ if response.respond_to?(:by_serial)
48
+ # O(log n) C-level lookup, no full-list materialisation. Only on recent
49
+ # openssl gems (https://github.com/ruby/openssl/pull/1065).
50
+ response.by_serial(cert.serial)
51
+ else
52
+ response.revoked.find { |r| r.serial == cert.serial }
53
+ end
54
+
60
55
  if revoked
61
56
  reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
62
57
  return [true, reason || "Unknown reason", revoked.time]
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.1"
14
+ VERSION = -"2.1.2"
15
15
 
16
16
  # Prefix for all cache keys so SSLTest entries coexist cleanly inside a shared
17
17
  # cache (e.g. Rails.cache).
@@ -117,10 +117,11 @@ module SSLTest
117
117
  # The order in which revocation check methods are tried for each certificate.
118
118
  # The first method to return a conclusive answer (ok or revoked) wins; the
119
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.
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).
122
123
  def revocation_order
123
- @revocation_order ||= %i[crl ocsp]
124
+ @revocation_order ||= %i[ocsp crl]
124
125
  end
125
126
 
126
127
  def revocation_order= order
@@ -52,7 +52,7 @@ describe SSLTest::MemoryStore do
52
52
  end
53
53
 
54
54
  # #size as reported through the default store after real CRL/OCSP fetches.
55
- describe "SSLTest.cache.size", retry: 5 do # examples hit live CRL/OCSP endpoints
55
+ describe "SSLTest.cache.size", retry: 2 do # examples hit live CRL/OCSP endpoints
56
56
  before { SSLTest.cache.clear }
57
57
 
58
58
  it "returns 0 by default" do
@@ -70,7 +70,9 @@ describe "SSLTest.cache.size", retry: 5 do # examples hit live CRL/OCSP endpoint
70
70
  end
71
71
 
72
72
  it "returns OCSP cache size properly" do
73
- 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")
74
76
  expect(SSLTest.cache.size[:ocsp][:responses]).to eq(1)
75
77
  expect(SSLTest.cache.size[:ocsp][:errors]).to eq(0)
76
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 the network-hitting describe blocks are tagged `retry: 5` (via
15
+ # one, and the network-hitting describe blocks are tagged `retry: 2` (via
16
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', retry: 5 do # examples hit live TLS/CRL/OCSP endpoints
30
+ describe '.test_url', retry: 2 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
@@ -66,7 +67,7 @@ describe SSLTest do
66
67
  expect(cert).to be_a OpenSSL::X509::Certificate
67
68
  end
68
69
 
69
- it "returns error on incomplete chain" do
70
+ it "returns error on incomplete chain", retry: 5 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)
@@ -81,8 +82,8 @@ describe SSLTest do
81
82
  end
82
83
 
83
84
  it "returns error on invalid host" do
84
- valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
85
- expect(error).to include('error code 62: hostname mismatch')
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
@@ -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,9 +120,9 @@ 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)
@@ -129,43 +130,45 @@ describe SSLTest do
129
130
  end
130
131
 
131
132
  it "returns error on revoked cert (CRL)" do
132
- # CRL is tried first and detects the revocation, so OCSP is never used
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
- # 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
137
- valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
138
- 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 confirm via #by_serial (recent openssl) or, as a
137
+ # fallback, #revoked, to extract the reason/date.
138
+ confirm = OpenSSL::X509::CRL.method_defined?(:by_serial) ? :by_serial : :revoked
139
+ expect_any_instance_of(OpenSSL::X509::CRL).to receive(confirm).and_call_original
140
+ valid, error, cert = SSLTest.test("https://revoked.testserver.host/")
141
+ expect(error).to match(/SSL certificate revoked: Unknown reason \(revocation date:/)
139
142
  expect(valid).to eq(false)
140
143
  expect(cert).to be_a OpenSSL::X509::Certificate
141
144
  end
142
145
 
143
146
  it "stops following redirection after the limit for the revoked certs check" do
144
147
  valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
145
- expect(error).to include("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension")
146
- expect(error).to include("OCSP: Request failed")
148
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
147
149
  expect(error).to include("Too many redirections (> 0)")
148
150
  expect(valid).to eq(true)
149
151
  expect(cert).to be_a OpenSSL::X509::Certificate
150
152
  end
151
153
 
152
154
  it "warns when the OCSP URI is missing" do
153
- # Disable CRL (tried first) to see the OCSP error message
154
- expect(SSLTest).to receive(:test_crl_revocation).twice.and_return([false, "skip CRL", nil])
155
+ # Disable CRL fallback to see the OCSP error message
156
+ expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
155
157
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
156
158
  valid, error, cert = SSLTest.test("https://google.com")
157
- expect(error).to eq ("Revocation test couldn't be performed: CRL: skip CRL, OCSP: Missing OCSP URI in authorityInfoAccess extension")
159
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
158
160
  expect(valid).to eq(true)
159
161
  expect(cert).to be_a OpenSSL::X509::Certificate
160
162
  end
161
163
 
162
164
  it "works with CRL only" do
163
- # CRL is tried first and succeeds for both certs, so OCSP is never used
165
+ # Disable OCSP so the CRL performs the revocation check for both certs
166
+ expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
164
167
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
165
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
166
168
  # Both certs are absent from their CRL, so the serial byte-search short-circuits
167
- # and we never materialise the (potentially huge) revoked list.
169
+ # and we never reach a per-serial lookup (let alone materialise the revoked list).
168
170
  expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:revoked)
171
+ expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:by_serial) if OpenSSL::X509::CRL.method_defined?(:by_serial)
169
172
  valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
170
173
  expect(error).to be_nil
171
174
  expect(valid).to eq(true)
@@ -177,15 +180,15 @@ describe SSLTest do
177
180
  expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
178
181
  expect(SSLTest).not_to receive(:follow_crl_redirects)
179
182
  valid, error, cert = SSLTest.test("https://github.com")
180
- expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension, OCSP: skip OCSP")
183
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
181
184
  expect(valid).to eq(true)
182
185
  expect(cert).to be_a OpenSSL::X509::Certificate
183
186
  end
184
187
 
185
- it "works with OCSP for first cert and CRL for intermediate (GitHub)" do
188
+ it "works with OCSP for first cert and CRL for intermediate (Google)" do
186
189
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
187
190
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
188
- valid, error, cert = SSLTest.test("https://github.com")
191
+ valid, error, cert = SSLTest.test("https://google.com")
189
192
  expect(error).to be_nil
190
193
  expect(valid).to eq(true)
191
194
  expect(cert).to be_a OpenSSL::X509::Certificate
@@ -226,7 +229,7 @@ describe SSLTest do
226
229
  expect(valid).to eq(true)
227
230
  expect(cert).to be_a OpenSSL::X509::Certificate
228
231
 
229
- expect($proxy).to have_received(:do_GET).twice
232
+ expect($proxy).to have_received(:do_GET).thrice
230
233
  end
231
234
  end
232
235
 
@@ -241,7 +244,7 @@ describe SSLTest do
241
244
  end
242
245
  end
243
246
 
244
- describe '.follow_crl_redirects', retry: 5 do # fetches a live CRL
247
+ describe '.follow_crl_redirects', retry: 2 do # fetches a live CRL
245
248
  before { SSLTest.cache.clear }
246
249
  # 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
247
250
  it "fetch CRL list and updates cache" do
@@ -272,7 +275,7 @@ describe SSLTest do
272
275
  end
273
276
  end
274
277
 
275
- describe '.cache', retry: 5 do # some examples hit live CRL/OCSP endpoints
278
+ describe '.cache', retry: 2 do # some examples hit live CRL/OCSP endpoints
276
279
  # Restore the default in-process store after tests that swap the backend so
277
280
  # global state doesn't leak between examples.
278
281
  after { SSLTest.cache = SSLTest::MemoryStore.new }
@@ -299,7 +302,7 @@ describe SSLTest do
299
302
  end
300
303
  end
301
304
 
302
- describe '.test_cert', retry: 5 do # revocation checks hit live CRL/OCSP endpoints
305
+ describe '.test_cert', retry: 2 do # revocation checks hit live CRL/OCSP endpoints
303
306
  it "returns no error on valid SNI website" do
304
307
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_mycs_com_client.pem')))
305
308
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_mycs_com_ca_bundle.pem')))
@@ -342,7 +345,7 @@ describe SSLTest do
342
345
  it "reports revocation exceptions" do
343
346
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/digicert_com_client.pem')))
344
347
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/digicert_com_ca_bundle.pem')))
345
- expect(SSLTest).to receive(:follow_crl_redirects).and_raise(ArgumentError.new("test"))
348
+ expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
346
349
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
347
350
  expect(error).to eq("SSL certificate test failed: test")
348
351
  expect(valid).to be_nil
@@ -353,9 +356,9 @@ describe SSLTest do
353
356
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_client.pem')))
354
357
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_ca_bundle.pem')))
355
358
 
356
- # CRL is tried first; disable it so OCSP performs the revocation check
357
- expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
359
+ # OCSP is tried first and detects the revocation, so CRL is never used
358
360
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
361
+ expect(SSLTest).not_to receive(:follow_crl_redirects)
359
362
 
360
363
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
361
364
  expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2025-06-09 15:07:39 UTC)")
@@ -367,9 +370,12 @@ describe SSLTest do
367
370
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_badssl_client.pem')))
368
371
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_badssl_ca_bundle.pem')))
369
372
 
370
- # CRL is tried first and detects the revocation, so OCSP is never used
373
+ # Disable OCSP (tried first) so the CRL performs the revocation check
374
+ expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
371
375
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
372
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
376
+ # On a serial byte-match we confirm via #by_serial (recent openssl) or #revoked
377
+ confirm = OpenSSL::X509::CRL.method_defined?(:by_serial) ? :by_serial : :revoked
378
+ expect_any_instance_of(OpenSSL::X509::CRL).to receive(confirm).and_call_original
373
379
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
374
380
  expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
375
381
  expect(valid).to eq(false)
@@ -381,8 +387,7 @@ describe SSLTest do
381
387
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
382
388
 
383
389
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle, redirection_limit: 0)
384
- expect(error).to include("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension")
385
- expect(error).to include("OCSP: Request failed")
390
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
386
391
  expect(error).to include("Too many redirections (> 0)")
387
392
  expect(valid).to eq(true)
388
393
  expect(cert).to eq(cert)
@@ -392,12 +397,12 @@ describe SSLTest do
392
397
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
393
398
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
394
399
 
395
- # Disable CRL (tried first) to see the OCSP error message
396
- expect(SSLTest).to receive(:test_crl_revocation).twice.and_return([false, "skip CRL", nil])
400
+ # Disable CRL fallback to see the OCSP error message
401
+ expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
397
402
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
398
403
 
399
404
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
400
- expect(error).to eq ("Revocation test couldn't be performed: CRL: skip CRL, OCSP: Missing OCSP URI in authorityInfoAccess extension")
405
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
401
406
  expect(valid).to eq(true)
402
407
  expect(cert).to eq(cert)
403
408
  end
@@ -406,9 +411,9 @@ describe SSLTest do
406
411
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_client.pem')))
407
412
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_ca_bundle.pem')))
408
413
 
409
- # CRL is tried first and succeeds for both certs, so OCSP is never used
414
+ # Disable OCSP so the CRL performs the revocation check for both certs
415
+ expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
410
416
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
411
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
412
417
 
413
418
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
414
419
  expect(error).to be_nil
@@ -425,18 +430,18 @@ describe SSLTest do
425
430
  expect(SSLTest).not_to receive(:follow_crl_redirects)
426
431
 
427
432
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
428
- expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension, OCSP: skip OCSP")
433
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
429
434
  expect(valid).to eq(true)
430
435
  expect(cert).to eq(cert)
431
436
 
432
437
  end
433
438
 
434
- it "works with OCSP for first cert and CRL for intermediate (GitHub)" do
439
+ it "works with OCSP for first cert and CRL for intermediate (Google)" do
435
440
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
436
441
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
437
442
 
438
- cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_github_com_client.pem')))
439
- ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
443
+ cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
444
+ ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
440
445
 
441
446
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
442
447
  expect(error).to be_nil
@@ -475,8 +480,7 @@ describe SSLTest do
475
480
  expect(valid).to eq(true)
476
481
  expect(cert).to eq(cert)
477
482
 
478
- # CRL is tried first, so both certs are checked via CRL (GET) through the proxy
479
- expect($proxy).to have_received(:do_GET).twice
483
+ expect($proxy).to have_received(:do_GET).once
480
484
  end
481
485
  end
482
486
 
@@ -495,14 +499,14 @@ describe SSLTest do
495
499
  end
496
500
 
497
501
  describe '.revocation_order' do # no network: dispatch logic is stubbed
498
- after { SSLTest.revocation_order = %i[crl ocsp] } # reset to the default
502
+ after { SSLTest.revocation_order = %i[ocsp crl] } # reset to the default
499
503
 
500
504
  let(:cert) { OpenSSL::X509::Certificate.new }
501
505
  let(:issuer) { OpenSSL::X509::Certificate.new }
502
506
  let(:chain) { [cert, issuer] }
503
507
 
504
- it "defaults to CRL first" do
505
- expect(SSLTest.revocation_order).to eq(%i[crl ocsp])
508
+ it "defaults to OCSP first" do
509
+ expect(SSLTest.revocation_order).to eq(%i[ocsp crl])
506
510
  end
507
511
 
508
512
  it "validates the value" do
@@ -510,23 +514,22 @@ describe SSLTest do
510
514
  expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
511
515
  end
512
516
 
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)
517
+ it "checks OCSP first by default, falling back to CRL on error" do
518
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return([false, "OCSP boom", nil])
519
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return(:crl_ok)
516
520
  result = SSLTest.send(:test_chain_revocation, chain)
517
521
  expect(result).to eq([false, nil, nil])
518
522
  end
519
523
 
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
+ it "checks CRL first when configured, falling back to OCSP on error" do
525
+ SSLTest.revocation_order = %i[crl ocsp]
526
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return([false, "CRL boom", nil])
527
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return(:ocsp_ok)
524
528
  result = SSLTest.send(:test_chain_revocation, chain)
525
529
  expect(result).to eq([false, nil, nil])
526
530
  end
527
531
 
528
532
  it "does not try the second method when the first one passes" do
529
- SSLTest.revocation_order = %i[ocsp crl]
530
533
  expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
531
534
  expect(SSLTest).not_to receive(:test_crl_revocation)
532
535
  expect(SSLTest.send(:test_chain_revocation, chain)).to eq([false, nil, nil])
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.1
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrien Rey-Jarthon
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-19 00:00:00.000000000 Z
10
+ date: 2026-06-23 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
  - - ">="