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 +4 -4
- data/README.md +8 -8
- data/by_serial_bench.rb +65 -0
- data/crl_bench.rb +122 -0
- data/lib/ssl-test/crl.rb +5 -18
- data/lib/ssl-test.rb +5 -4
- data/spec/memory_store_spec.rb +3 -1
- data/spec/ssl-test_spec.rb +45 -48
- data/ssl-test.gemspec +2 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48563a079839f759ca0f765c768fa7280af962036d2e1e9acd81aaa2dd8967fb
|
|
4
|
+
data.tar.gz: 2a51edf744b6116222f52ea1b72b502805f2a2c78beab94620b408a8c7b05d07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [
|
|
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
|
|
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
|
|
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
|
|
77
|
-
SSLTest.revocation_order = %i[crl
|
|
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
|
|
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
|
-
|
|
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
|
data/by_serial_bench.rb
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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.
|
|
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
|
|
121
|
-
#
|
|
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
|
|
124
|
+
@revocation_order ||= %i[ocsp crl]
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
def revocation_order= order
|
data/spec/memory_store_spec.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/spec/ssl-test_spec.rb
CHANGED
|
@@ -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://
|
|
85
|
-
expect(error).to
|
|
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(:
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
138
|
-
expect(error).to
|
|
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:
|
|
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
|
|
154
|
-
expect(SSLTest).to receive(:test_crl_revocation).
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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://
|
|
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(:
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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:
|
|
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
|
|
396
|
-
expect(SSLTest).to receive(:test_crl_revocation).
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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/
|
|
439
|
-
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/
|
|
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
|
-
|
|
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
|
|
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
|
|
505
|
-
expect(SSLTest.revocation_order).to eq(%i[crl
|
|
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
|
|
514
|
-
expect(SSLTest).to receive(:
|
|
515
|
-
expect(SSLTest).to receive(:
|
|
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
|
|
521
|
-
SSLTest.revocation_order = %i[ocsp
|
|
522
|
-
expect(SSLTest).to receive(:
|
|
523
|
-
expect(SSLTest).to receive(:
|
|
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.
|
|
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-
|
|
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: '
|
|
179
|
+
version: '3.1'
|
|
178
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
179
181
|
requirements:
|
|
180
182
|
- - ">="
|