ssl-test 2.0.1 → 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: 073a878a42dbba9b30e7c69ec4b435cd8f2c607e59a6b894099af73ea60526f8
4
- data.tar.gz: 709b2f097b7d8a61922f2cfcee0cb6651046c57854dea0b3dc17c8367b7b856a
3
+ metadata.gz: 48563a079839f759ca0f765c768fa7280af962036d2e1e9acd81aaa2dd8967fb
4
+ data.tar.gz: 2a51edf744b6116222f52ea1b72b502805f2a2c78beab94620b408a8c7b05d07
5
5
  SHA512:
6
- metadata.gz: 150c1d97a77c0a37c71a180d7cd643f3a4264280f65261e2cb830b184fed00cb60458bf4d27ce1eb52225db506398d2e1480bf69d5a7d6ffc4ee7e5ad288a5b1
7
- data.tar.gz: '08f66249c1b7d951b71283589d957b6bfe4451934ff155af255e4e31a5f77d0aa27bcd1dcc9cee1dcb0b1ddc450618d97c9a445c5067990009b14ad616933128'
6
+ metadata.gz: bd2cb2d7de4d133df831932a4d8a44974fc49dd592765fa7669f9d272c1e169d8ea90c728109824dafc89d20a11d83da135f9a7852466781bc58890dcc3a322a
7
+ data.tar.gz: b96a5c2515c952f25ebe823c88d5daf2e8bcc60024d76a7ea715fc854b199bb4e1e06f4d3f787490a6f9bfb987eaba3f4be5692a063a74b4f55fa605e3e8c7e4
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,8 @@ 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.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
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
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)
207
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
@@ -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
 
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.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).
@@ -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
@@ -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
@@ -15,6 +15,7 @@ RSpec.configure do |config|
15
15
  # one, and the network-hitting describe blocks are tagged `retry: 5` (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
 
@@ -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,40 +130,39 @@ 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
136
  # On a serial byte-match we fall back to #revoked to extract the reason/date
136
137
  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)")
138
+ valid, error, cert = SSLTest.test("https://revoked.testserver.host/")
139
+ expect(error).to match(/SSL certificate revoked: Unknown reason \(revocation date:/)
139
140
  expect(valid).to eq(false)
140
141
  expect(cert).to be_a OpenSSL::X509::Certificate
141
142
  end
142
143
 
143
144
  it "stops following redirection after the limit for the revoked certs check" do
144
145
  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")
146
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
147
147
  expect(error).to include("Too many redirections (> 0)")
148
148
  expect(valid).to eq(true)
149
149
  expect(cert).to be_a OpenSSL::X509::Certificate
150
150
  end
151
151
 
152
152
  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])
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])
155
155
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
156
156
  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")
157
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
158
158
  expect(valid).to eq(true)
159
159
  expect(cert).to be_a OpenSSL::X509::Certificate
160
160
  end
161
161
 
162
162
  it "works with CRL only" do
163
- # 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])
164
165
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
165
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
166
166
  # Both certs are absent from their CRL, so the serial byte-search short-circuits
167
167
  # and we never materialise the (potentially huge) revoked list.
168
168
  expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:revoked)
@@ -177,15 +177,15 @@ describe SSLTest do
177
177
  expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
178
178
  expect(SSLTest).not_to receive(:follow_crl_redirects)
179
179
  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")
180
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
181
181
  expect(valid).to eq(true)
182
182
  expect(cert).to be_a OpenSSL::X509::Certificate
183
183
  end
184
184
 
185
- 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
186
186
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
187
187
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
188
- valid, error, cert = SSLTest.test("https://github.com")
188
+ valid, error, cert = SSLTest.test("https://google.com")
189
189
  expect(error).to be_nil
190
190
  expect(valid).to eq(true)
191
191
  expect(cert).to be_a OpenSSL::X509::Certificate
@@ -342,7 +342,7 @@ describe SSLTest do
342
342
  it "reports revocation exceptions" do
343
343
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/digicert_com_client.pem')))
344
344
  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"))
345
+ expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
346
346
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
347
347
  expect(error).to eq("SSL certificate test failed: test")
348
348
  expect(valid).to be_nil
@@ -353,9 +353,9 @@ describe SSLTest do
353
353
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_client.pem')))
354
354
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_ca_bundle.pem')))
355
355
 
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])
356
+ # OCSP is tried first and detects the revocation, so CRL is never used
358
357
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
358
+ expect(SSLTest).not_to receive(:follow_crl_redirects)
359
359
 
360
360
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
361
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)")
@@ -367,9 +367,9 @@ describe SSLTest do
367
367
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_badssl_client.pem')))
368
368
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_badssl_ca_bundle.pem')))
369
369
 
370
- # 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])
371
372
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
372
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
373
373
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
374
374
  expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
375
375
  expect(valid).to eq(false)
@@ -381,8 +381,7 @@ describe SSLTest do
381
381
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
382
382
 
383
383
  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")
384
+ expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
386
385
  expect(error).to include("Too many redirections (> 0)")
387
386
  expect(valid).to eq(true)
388
387
  expect(cert).to eq(cert)
@@ -392,12 +391,12 @@ describe SSLTest do
392
391
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
393
392
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
394
393
 
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])
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])
397
396
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
398
397
 
399
398
  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")
399
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
401
400
  expect(valid).to eq(true)
402
401
  expect(cert).to eq(cert)
403
402
  end
@@ -406,9 +405,9 @@ describe SSLTest do
406
405
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_client.pem')))
407
406
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_ca_bundle.pem')))
408
407
 
409
- # 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])
410
410
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
411
- expect(SSLTest).not_to receive(:test_ocsp_revocation)
412
411
 
413
412
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
414
413
  expect(error).to be_nil
@@ -425,18 +424,18 @@ describe SSLTest do
425
424
  expect(SSLTest).not_to receive(:follow_crl_redirects)
426
425
 
427
426
  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")
427
+ expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
429
428
  expect(valid).to eq(true)
430
429
  expect(cert).to eq(cert)
431
430
 
432
431
  end
433
432
 
434
- 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
435
434
  expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
436
435
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
437
436
 
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')))
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')))
440
439
 
441
440
  valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
442
441
  expect(error).to be_nil
@@ -475,8 +474,7 @@ describe SSLTest do
475
474
  expect(valid).to eq(true)
476
475
  expect(cert).to eq(cert)
477
476
 
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
477
+ expect($proxy).to have_received(:do_GET).once
480
478
  end
481
479
  end
482
480
 
@@ -495,14 +493,14 @@ describe SSLTest do
495
493
  end
496
494
 
497
495
  describe '.revocation_order' do # no network: dispatch logic is stubbed
498
- after { SSLTest.revocation_order = %i[crl ocsp] } # reset to the default
496
+ after { SSLTest.revocation_order = %i[ocsp crl] } # reset to the default
499
497
 
500
498
  let(:cert) { OpenSSL::X509::Certificate.new }
501
499
  let(:issuer) { OpenSSL::X509::Certificate.new }
502
500
  let(:chain) { [cert, issuer] }
503
501
 
504
- it "defaults to CRL first" do
505
- expect(SSLTest.revocation_order).to eq(%i[crl ocsp])
502
+ it "defaults to OCSP first" do
503
+ expect(SSLTest.revocation_order).to eq(%i[ocsp crl])
506
504
  end
507
505
 
508
506
  it "validates the value" do
@@ -510,23 +508,22 @@ describe SSLTest do
510
508
  expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
511
509
  end
512
510
 
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)
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)
516
514
  result = SSLTest.send(:test_chain_revocation, chain)
517
515
  expect(result).to eq([false, nil, nil])
518
516
  end
519
517
 
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)
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)
524
522
  result = SSLTest.send(:test_chain_revocation, chain)
525
523
  expect(result).to eq([false, nil, nil])
526
524
  end
527
525
 
528
526
  it "does not try the second method when the first one passes" do
529
- SSLTest.revocation_order = %i[ocsp crl]
530
527
  expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
531
528
  expect(SSLTest).not_to receive(:test_crl_revocation)
532
529
  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.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-19 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
  - - ">="