ssl-test 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +5 -1
- data/README.md +14 -6
- data/by_serial_bench.rb +65 -0
- data/crl_bench.rb +122 -0
- data/lib/ssl-test/crl.rb +22 -26
- data/lib/ssl-test.rb +43 -14
- data/spec/memory_store_spec.rb +5 -2
- data/spec/ssl-test_spec.rb +98 -49
- 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/.github/workflows/ruby.yml
CHANGED
|
@@ -3,6 +3,10 @@ on: [push]
|
|
|
3
3
|
jobs:
|
|
4
4
|
specs:
|
|
5
5
|
runs-on: ubuntu-22.04
|
|
6
|
+
strategy:
|
|
7
|
+
fail-fast: false
|
|
8
|
+
matrix:
|
|
9
|
+
ruby-version: ['3.2', '3.3', '3.4']
|
|
6
10
|
services:
|
|
7
11
|
redis:
|
|
8
12
|
image: redis
|
|
@@ -16,7 +20,7 @@ jobs:
|
|
|
16
20
|
- name: Set up Ruby
|
|
17
21
|
uses: ruby/setup-ruby@v1
|
|
18
22
|
with:
|
|
19
|
-
ruby-version:
|
|
23
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
20
24
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
21
25
|
- name: Run specs
|
|
22
26
|
run: |
|
data/README.md
CHANGED
|
@@ -59,7 +59,7 @@ error # => nil
|
|
|
59
59
|
cert # => #<OpenSSL::X509::Certificate...>
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Revoked certificates are detected using [
|
|
62
|
+
Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) by default:
|
|
63
63
|
|
|
64
64
|
```ruby
|
|
65
65
|
valid, error, cert = SSLTest.test_url "https://revoked.badssl.com"
|
|
@@ -68,7 +68,14 @@ error # => "SSL certificate revoked: Key Compromise (revocation date: 2019-10-07
|
|
|
68
68
|
cert # => #<OpenSSL::X509::Certificate...>
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
If the
|
|
71
|
+
If the OCSP endpoint is missing, invalid or unreachable the certificate revocation will be tested using the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list).
|
|
72
|
+
|
|
73
|
+
You can swap the order if you'd rather check CRL first and only fall back to OCSP on error (the default is OCSP first, since 2.1, because CRLs can be large and checking them is significantly more memory- and CPU-intensive):
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
SSLTest.revocation_order = %i[crl ocsp] # CRL first, OCSP fallback
|
|
77
|
+
SSLTest.revocation_order = %i[ocsp crl] # the default: OCSP first, CRL fallback
|
|
78
|
+
```
|
|
72
79
|
|
|
73
80
|
If both CRL and OCSP tests are impossible, the certificate will still be considered valid but with an error message:
|
|
74
81
|
|
|
@@ -96,7 +103,7 @@ This check will pass for self-signed certificates if the certificate is signed b
|
|
|
96
103
|
|
|
97
104
|
SSLTester connects as an HTTPS client (without issuing any requests) and then closes the connection. It does so using ruby `net/https` library and verifies the SSL status. It also hooks into the validation process to intercept the raw certificate for you.
|
|
98
105
|
|
|
99
|
-
After that it
|
|
106
|
+
After that it queries the [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint to verify if the certificate has been revoked. If the OCSP endpoint is not available it'll fetch the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) instead. It does this for every certificates in the chain (except the root which is trusted by your Operating System). It is possible the first one will be validated with OCSP and the intermediate with CRL depending on what they offer.
|
|
100
107
|
|
|
101
108
|
### Caching
|
|
102
109
|
|
|
@@ -193,9 +200,10 @@ But also **revoked certs** like most browsers (not handled by `curl`)
|
|
|
193
200
|
|
|
194
201
|
## Changelog
|
|
195
202
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
* 2.0.
|
|
203
|
+
* 2.1.1 - 2026-06-21: Refactor CRL code to use newer OpenSSL `crl_uris` helper
|
|
204
|
+
* 2.1.0 - 2026-06-20: Check revocation with OCSP first and fall back to CRL (was CRL first since 1.6) because CRLs can be large and checking them is significantly more memory- and CPU-intensive. Set `SSLTest.revocation_order = %i[crl ocsp]` to restore the previous order.
|
|
205
|
+
* 2.0.1 - 2026-06-19: Speed up and shrink the memory use of CRL checks with a fast path that scans the raw CRL for the certificate's serial before parsing, avoiding instantiating the entire revocation list (>1M Ruby objects for busy CAs) when the cert isn't revoked. Send both `If-None-Match` and `If-Modified-Since` on CRL revalidation so CDN-backed CAs that don't honor their own ETag (e.g. DigiCert) still return a `304` instead of re-downloading the whole list.
|
|
206
|
+
* 2.0.0 - 2026-06-16: Make the revocation check order configurable via `SSLTest.revocation_order` (`%i[crl ocsp]` by default, set `%i[ocsp crl]` to check OCSP first). Make the cache backend configurable. The default stays an in-process `SSLTest::MemoryStore`, but you can now assign any object responding to the `Rails.cache`-style API (`read`/`write`/`delete`) with `SSLTest.cache = Rails.cache` to share responses across processes and get compression (e.g. memcache via Dalli — see the memcached note in the Caching section about raising the max value size for large CRLs). **Breaking:** the module-level `SSLTest.cache_size` and `SSLTest.flush_cache` were removed — use `SSLTest.cache.size` and `SSLTest.cache.clear` instead (these only work with the built-in `MemoryStore`; shared backends like `Rails.cache` can't be enumerated and shouldn't be wholesale-cleared)
|
|
199
207
|
* 1.6.0 - 2026-06-16: Check revocation with CRL first and fall back to OCSP (was OCSP first) to reduce revocation detection delay
|
|
200
208
|
* 1.5.0 - 2025-11-28: Add support for local certificates testing and HTTP proxies (#8), changed `#test` method into `#test_url` and `#test_cert` (`#test` remains as an alias for `#test_url` for backward-compatibility)
|
|
201
209
|
* 1.4.1 - 2022-10-24: Add support for "tcps://" scheme
|
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,46 +12,41 @@ module SSLTest
|
|
|
12
12
|
# A note about caching:
|
|
13
13
|
# I choose to only cache the raw HTTP body here (and not the parsed list or better a hash
|
|
14
14
|
# indexed by certificat serial). This is not CPU efficient because it means every time we
|
|
15
|
-
# need to check a cert from a cached CRL we need to
|
|
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
|
|
27
|
+
crl = cert.crl_uris&.first
|
|
28
|
+
return [false, "Missing crlDistributionPoints extension", nil] if crl.nil?
|
|
40
29
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
crl_uri = URI(crl[/URI:(.*)/, 1])
|
|
30
|
+
crl_uri = URI(crl)
|
|
44
31
|
http_response, crl_request_error = follow_crl_redirects(crl_uri, **options)
|
|
45
32
|
return [false, "Request failed (URI: #{crl_uri}): #{crl_request_error}", nil] unless http_response
|
|
46
33
|
|
|
47
34
|
response = OpenSSL::X509::CRL.new http_response
|
|
48
35
|
return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)
|
|
49
36
|
|
|
37
|
+
# Fast path: scan the raw response for the cert's serial encoded as DER.
|
|
38
|
+
# In most case (not revoked) this lets us skip response.revoked, which
|
|
39
|
+
# instantiate the *entire* revocation list as Ruby objects (>1M objects for busy CAs)
|
|
40
|
+
serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
|
|
41
|
+
return :crl_ok unless response.to_der.include?(serial_der)
|
|
42
|
+
|
|
43
|
+
# The serial's bytes appear (a real hit, or a rare collision):
|
|
44
|
+
# confirm authoritatively and pull the reason/date. The costly revoked-list
|
|
45
|
+
# materialisation only happens here, i.e. for actually-revoked certs.
|
|
50
46
|
revoked = response.revoked.find { |r| r.serial == cert.serial }
|
|
51
47
|
if revoked
|
|
52
48
|
reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
|
|
53
49
|
return [true, reason || "Unknown reason", revoked.time]
|
|
54
|
-
else
|
|
55
50
|
end
|
|
56
51
|
|
|
57
52
|
:crl_ok
|
|
@@ -77,12 +72,13 @@ module SSLTest
|
|
|
77
72
|
http.read_timeout = read_timeout
|
|
78
73
|
|
|
79
74
|
req = Net::HTTP::Get.new(path)
|
|
80
|
-
# Include conditional caching headers from cache to save bandwidth if
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
# Include conditional caching headers from cache to save bandwidth if the
|
|
76
|
+
# list didn't change (304). Send both validators when present: some
|
|
77
|
+
# CDN-backed CAs (e.g. DigiCert) serve per-node ETags they won't honor via
|
|
78
|
+
# If-None-Match but will revalidate via If-Modified-Since, so sending only
|
|
79
|
+
# the ETag defeats the 304 and re-downloads the whole list every time.
|
|
80
|
+
req["If-None-Match"] = cache_entry[:etag] if cache_entry&.[](:etag)
|
|
81
|
+
req["If-Modified-Since"] = cache_entry[:last_mod] if cache_entry&.[](:last_mod)
|
|
86
82
|
http_response = http.request(req)
|
|
87
83
|
case http_response
|
|
88
84
|
when Net::HTTPNotModified
|
|
@@ -93,7 +89,7 @@ module SSLTest
|
|
|
93
89
|
when Net::HTTPSuccess
|
|
94
90
|
# Success, update (or add to) cache and return frech body
|
|
95
91
|
@logger&.debug { "SSLTest + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
|
|
96
|
-
@logger&.warn { "SSLTest + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
|
|
92
|
+
@logger&.warn { "SSLTest + CRL: Warning: massive file size (#{http_response.body.bytesize} bytes)" } if http_response.body.bytesize > 1024**2 # 1MB
|
|
97
93
|
@logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
|
|
98
94
|
cache.write(cache_key, {
|
|
99
95
|
body: http_response.body,
|
data/lib/ssl-test.rb
CHANGED
|
@@ -11,7 +11,7 @@ module SSLTest
|
|
|
11
11
|
extend OCSP
|
|
12
12
|
extend CRL
|
|
13
13
|
|
|
14
|
-
VERSION = -"2.
|
|
14
|
+
VERSION = -"2.1.1"
|
|
15
15
|
|
|
16
16
|
# Prefix for all cache keys so SSLTest entries coexist cleanly inside a shared
|
|
17
17
|
# cache (e.g. Rails.cache).
|
|
@@ -114,6 +114,24 @@ module SSLTest
|
|
|
114
114
|
@logger = logger
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# The order in which revocation check methods are tried for each certificate.
|
|
118
|
+
# The first method to return a conclusive answer (ok or revoked) wins; the
|
|
119
|
+
# next is only tried when the previous one errors out (missing endpoint,
|
|
120
|
+
# network error, etc.). Defaults to OCSP first, since CRLs can be large and
|
|
121
|
+
# checking them is significantly more memory- and CPU-intensive. Set to
|
|
122
|
+
# %i[crl ocsp] to check CRL first (e.g. to reduce revocation propagation delay).
|
|
123
|
+
def revocation_order
|
|
124
|
+
@revocation_order ||= %i[ocsp crl]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def revocation_order= order
|
|
128
|
+
order = Array(order).map { |m| m.to_sym }
|
|
129
|
+
unless order.sort == %i[crl ocsp]
|
|
130
|
+
raise ArgumentError, "SSLTest.revocation_order must be %i[crl ocsp] or %i[ocsp crl], got #{order.inspect}"
|
|
131
|
+
end
|
|
132
|
+
@revocation_order = order
|
|
133
|
+
end
|
|
134
|
+
|
|
117
135
|
private
|
|
118
136
|
|
|
119
137
|
def revocation_message(revoked, revocation_date, message)
|
|
@@ -147,26 +165,37 @@ module SSLTest
|
|
|
147
165
|
chain[0..-2].each_with_index do |cert, i|
|
|
148
166
|
@logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
|
|
149
167
|
|
|
150
|
-
# Try
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
168
|
+
# Try each revocation method in the configured order, falling back to the
|
|
169
|
+
# next one only when the current method errors out.
|
|
170
|
+
errors = {}
|
|
171
|
+
passed = false
|
|
172
|
+
revocation_order.each do |method|
|
|
173
|
+
result = test_revocation(method, cert, issuer: chain[i + 1], chain: chain, **options)
|
|
174
|
+
@logger&.debug { "SSLTest + #{method.to_s.upcase}: #{result}" }
|
|
175
|
+
if result == :"#{method}_ok" # passed, go to next cert
|
|
176
|
+
passed = true
|
|
177
|
+
break
|
|
178
|
+
end
|
|
179
|
+
return result if result[0] == true # revoked
|
|
180
|
+
errors[method] = result[1] # errored, try the next method
|
|
181
|
+
end
|
|
182
|
+
next if passed
|
|
161
183
|
|
|
162
|
-
# If
|
|
163
|
-
return [false, "
|
|
184
|
+
# If all methods failed, return a soft fail with a combination of the error messages
|
|
185
|
+
return [false, errors.map { |method, message| "#{method.to_s.upcase}: #{message}" }.join(", "), nil]
|
|
164
186
|
end
|
|
165
187
|
|
|
166
188
|
# If all test passed, the certificate is not revoked
|
|
167
189
|
[false, nil, nil]
|
|
168
190
|
end
|
|
169
191
|
|
|
192
|
+
def test_revocation method, cert, **options
|
|
193
|
+
case method
|
|
194
|
+
when :crl then test_crl_revocation(cert, **options)
|
|
195
|
+
when :ocsp then test_ocsp_revocation(cert, **options)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
170
199
|
def cert_field_to_hash field
|
|
171
200
|
field.to_a.each.with_object({}) do |v, h|
|
|
172
201
|
v = v.to_a
|
data/spec/memory_store_spec.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "ssl-test"
|
|
2
|
+
require "rspec/retry" # the cache.size examples below hit live CRL/OCSP endpoints
|
|
2
3
|
|
|
3
4
|
describe SSLTest::MemoryStore do
|
|
4
5
|
subject(:store) { described_class.new }
|
|
@@ -51,7 +52,7 @@ describe SSLTest::MemoryStore do
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
# #size as reported through the default store after real CRL/OCSP fetches.
|
|
54
|
-
describe "SSLTest.cache.size" do
|
|
55
|
+
describe "SSLTest.cache.size", retry: 5 do # examples hit live CRL/OCSP endpoints
|
|
55
56
|
before { SSLTest.cache.clear }
|
|
56
57
|
|
|
57
58
|
it "returns 0 by default" do
|
|
@@ -69,7 +70,9 @@ describe "SSLTest.cache.size" do
|
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
it "returns OCSP cache size properly" do
|
|
72
|
-
|
|
73
|
+
# Google's leaf is checked via OCSP and its intermediate (no OCSP URI) via CRL,
|
|
74
|
+
# so this populates both the OCSP and CRL caches under the default OCSP-first order.
|
|
75
|
+
SSLTest.test("https://google.com")
|
|
73
76
|
expect(SSLTest.cache.size[:ocsp][:responses]).to eq(1)
|
|
74
77
|
expect(SSLTest.cache.size[:ocsp][:errors]).to eq(0)
|
|
75
78
|
expect(SSLTest.cache.size[:ocsp][:bytes]).to be > 0
|
data/spec/ssl-test_spec.rb
CHANGED
|
@@ -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
|
|
16
|
-
# transient network blips don't fail the suite.
|
|
15
|
+
# one, and the network-hitting describe blocks are tagged `retry: 5` (via
|
|
16
|
+
# rspec-retry) so transient network blips don't fail the suite.
|
|
17
17
|
config.verbose_retry = true
|
|
18
|
+
config.display_try_failure_messages = true
|
|
18
19
|
config.default_sleep_interval = 1
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -26,7 +27,7 @@ describe SSLTest do
|
|
|
26
27
|
|
|
27
28
|
after(:each) { proxy_thread&.kill }
|
|
28
29
|
|
|
29
|
-
describe '.test_url' do
|
|
30
|
+
describe '.test_url', retry: 5 do # examples hit live TLS/CRL/OCSP endpoints
|
|
30
31
|
it "returns no error on valid SNI website" do
|
|
31
32
|
valid, error, cert = SSLTest.test("https://www.mycs.com")
|
|
32
33
|
expect(error).to be_nil
|
|
@@ -59,35 +60,35 @@ describe SSLTest do
|
|
|
59
60
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
it "returns error on self signed certificate"
|
|
63
|
+
it "returns error on self signed certificate" do
|
|
63
64
|
valid, error, cert = SSLTest.test("https://self-signed.testserver.host/")
|
|
64
65
|
expect(error).to eq ("error code 18: self-signed certificate")
|
|
65
66
|
expect(valid).to eq(false)
|
|
66
67
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
it "returns error on incomplete chain"
|
|
70
|
+
it "returns error on incomplete chain" do
|
|
70
71
|
valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
|
|
71
72
|
expect(error).to eq ("error code 20: unable to get local issuer certificate")
|
|
72
73
|
expect(valid).to eq(false)
|
|
73
74
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
it "returns error on untrusted root"
|
|
77
|
+
it "returns error on untrusted root" do
|
|
77
78
|
valid, error, cert = SSLTest.test("https://untrusted-root.testserver.host/")
|
|
78
79
|
expect(error).to eq ("error code 19: self-signed certificate in certificate chain")
|
|
79
80
|
expect(valid).to eq(false)
|
|
80
81
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
it "returns error on invalid host"
|
|
84
|
-
valid, error, cert = SSLTest.test("https://
|
|
85
|
-
expect(error).to
|
|
84
|
+
it "returns error on invalid host" do
|
|
85
|
+
valid, error, cert = SSLTest.test("https://db3.updn.io/")
|
|
86
|
+
expect(error).to eq('error code 62: hostname mismatch')
|
|
86
87
|
expect(valid).to eq(false)
|
|
87
88
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
it "returns error on expired cert"
|
|
91
|
+
it "returns error on expired cert" do
|
|
91
92
|
valid, error, cert = SSLTest.test("https://expired-rsa-dv.ssl.com/")
|
|
92
93
|
expect(error).to eq ("error code 10: certificate has expired")
|
|
93
94
|
expect(valid).to eq(false)
|
|
@@ -111,7 +112,7 @@ describe SSLTest do
|
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
it "reports revocation exceptions" do
|
|
114
|
-
expect(SSLTest).to receive(:
|
|
115
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
|
|
115
116
|
valid, error, cert = SSLTest.test("https://digicert.com")
|
|
116
117
|
expect(error).to eq ("SSL certificate test failed: test")
|
|
117
118
|
expect(valid).to be_nil
|
|
@@ -119,48 +120,52 @@ describe SSLTest do
|
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
it "returns error on revoked cert (OCSP)" do
|
|
122
|
-
#
|
|
123
|
-
expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
|
|
123
|
+
# OCSP is tried first and detects the revocation, so CRL is never used
|
|
124
124
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
125
|
+
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
|
125
126
|
valid, error, cert = SSLTest.test("https://revoked-rsa-dv.ssl.com/")
|
|
126
127
|
expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unspecified reason (revocation date: 2026-06-09 14:37:38 UTC)")
|
|
127
128
|
expect(valid).to eq(false)
|
|
128
129
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
129
130
|
end
|
|
130
131
|
|
|
131
|
-
it "returns error on revoked cert (CRL)"
|
|
132
|
-
#
|
|
132
|
+
it "returns error on revoked cert (CRL)" do
|
|
133
|
+
# Disable OCSP (tried first) so the CRL performs the revocation check
|
|
134
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
|
|
133
135
|
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
# On a serial byte-match we fall back to #revoked to extract the reason/date
|
|
137
|
+
expect_any_instance_of(OpenSSL::X509::CRL).to receive(:revoked).and_call_original
|
|
138
|
+
valid, error, cert = SSLTest.test("https://revoked.testserver.host/")
|
|
139
|
+
expect(error).to match(/SSL certificate revoked: Unknown reason \(revocation date:/)
|
|
137
140
|
expect(valid).to eq(false)
|
|
138
141
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
139
142
|
end
|
|
140
143
|
|
|
141
144
|
it "stops following redirection after the limit for the revoked certs check" do
|
|
142
145
|
valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
|
|
143
|
-
expect(error).to include("Revocation test couldn't be performed:
|
|
144
|
-
expect(error).to include("OCSP: Request failed")
|
|
146
|
+
expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
|
|
145
147
|
expect(error).to include("Too many redirections (> 0)")
|
|
146
148
|
expect(valid).to eq(true)
|
|
147
149
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
148
150
|
end
|
|
149
151
|
|
|
150
152
|
it "warns when the OCSP URI is missing" do
|
|
151
|
-
# Disable CRL
|
|
152
|
-
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])
|
|
153
155
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
154
156
|
valid, error, cert = SSLTest.test("https://google.com")
|
|
155
|
-
expect(error).to eq ("Revocation test couldn't be performed:
|
|
157
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
|
|
156
158
|
expect(valid).to eq(true)
|
|
157
159
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
158
160
|
end
|
|
159
161
|
|
|
160
162
|
it "works with CRL only" do
|
|
161
|
-
# CRL
|
|
163
|
+
# Disable OCSP so the CRL performs the revocation check for both certs
|
|
164
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
|
|
162
165
|
expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
|
|
163
|
-
|
|
166
|
+
# Both certs are absent from their CRL, so the serial byte-search short-circuits
|
|
167
|
+
# and we never materialise the (potentially huge) revoked list.
|
|
168
|
+
expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:revoked)
|
|
164
169
|
valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
|
|
165
170
|
expect(error).to be_nil
|
|
166
171
|
expect(valid).to eq(true)
|
|
@@ -172,15 +177,15 @@ describe SSLTest do
|
|
|
172
177
|
expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
|
|
173
178
|
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
|
174
179
|
valid, error, cert = SSLTest.test("https://github.com")
|
|
175
|
-
expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension
|
|
180
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
|
|
176
181
|
expect(valid).to eq(true)
|
|
177
182
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
178
183
|
end
|
|
179
184
|
|
|
180
|
-
it "works with OCSP for first cert and CRL for intermediate (
|
|
185
|
+
it "works with OCSP for first cert and CRL for intermediate (Google)" do
|
|
181
186
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
182
187
|
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
|
183
|
-
valid, error, cert = SSLTest.test("https://
|
|
188
|
+
valid, error, cert = SSLTest.test("https://google.com")
|
|
184
189
|
expect(error).to be_nil
|
|
185
190
|
expect(valid).to eq(true)
|
|
186
191
|
expect(cert).to be_a OpenSSL::X509::Certificate
|
|
@@ -236,7 +241,7 @@ describe SSLTest do
|
|
|
236
241
|
end
|
|
237
242
|
end
|
|
238
243
|
|
|
239
|
-
describe '.follow_crl_redirects' do
|
|
244
|
+
describe '.follow_crl_redirects', retry: 5 do # fetches a live CRL
|
|
240
245
|
before { SSLTest.cache.clear }
|
|
241
246
|
# 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
|
|
242
247
|
it "fetch CRL list and updates cache" do
|
|
@@ -267,7 +272,7 @@ describe SSLTest do
|
|
|
267
272
|
end
|
|
268
273
|
end
|
|
269
274
|
|
|
270
|
-
describe '.cache' do
|
|
275
|
+
describe '.cache', retry: 5 do # some examples hit live CRL/OCSP endpoints
|
|
271
276
|
# Restore the default in-process store after tests that swap the backend so
|
|
272
277
|
# global state doesn't leak between examples.
|
|
273
278
|
after { SSLTest.cache = SSLTest::MemoryStore.new }
|
|
@@ -294,7 +299,7 @@ describe SSLTest do
|
|
|
294
299
|
end
|
|
295
300
|
end
|
|
296
301
|
|
|
297
|
-
describe '.test_cert' do
|
|
302
|
+
describe '.test_cert', retry: 5 do # revocation checks hit live CRL/OCSP endpoints
|
|
298
303
|
it "returns no error on valid SNI website" do
|
|
299
304
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_mycs_com_client.pem')))
|
|
300
305
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_mycs_com_ca_bundle.pem')))
|
|
@@ -337,7 +342,7 @@ describe SSLTest do
|
|
|
337
342
|
it "reports revocation exceptions" do
|
|
338
343
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/digicert_com_client.pem')))
|
|
339
344
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/digicert_com_ca_bundle.pem')))
|
|
340
|
-
expect(SSLTest).to receive(:
|
|
345
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
|
|
341
346
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
342
347
|
expect(error).to eq("SSL certificate test failed: test")
|
|
343
348
|
expect(valid).to be_nil
|
|
@@ -348,9 +353,9 @@ describe SSLTest do
|
|
|
348
353
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_client.pem')))
|
|
349
354
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_rsa_dv_ca_bundle.pem')))
|
|
350
355
|
|
|
351
|
-
#
|
|
352
|
-
expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
|
|
356
|
+
# OCSP is tried first and detects the revocation, so CRL is never used
|
|
353
357
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
358
|
+
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
|
354
359
|
|
|
355
360
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
356
361
|
expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2025-06-09 15:07:39 UTC)")
|
|
@@ -362,9 +367,9 @@ describe SSLTest do
|
|
|
362
367
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/revoked_badssl_client.pem')))
|
|
363
368
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/revoked_badssl_ca_bundle.pem')))
|
|
364
369
|
|
|
365
|
-
#
|
|
370
|
+
# Disable OCSP (tried first) so the CRL performs the revocation check
|
|
371
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
|
|
366
372
|
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
|
367
|
-
expect(SSLTest).not_to receive(:test_ocsp_revocation)
|
|
368
373
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
369
374
|
expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
|
|
370
375
|
expect(valid).to eq(false)
|
|
@@ -376,8 +381,7 @@ describe SSLTest do
|
|
|
376
381
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_github_com_ca_bundle.pem')))
|
|
377
382
|
|
|
378
383
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle, redirection_limit: 0)
|
|
379
|
-
expect(error).to include("Revocation test couldn't be performed:
|
|
380
|
-
expect(error).to include("OCSP: Request failed")
|
|
384
|
+
expect(error).to include("Revocation test couldn't be performed: OCSP: Request failed")
|
|
381
385
|
expect(error).to include("Too many redirections (> 0)")
|
|
382
386
|
expect(valid).to eq(true)
|
|
383
387
|
expect(cert).to eq(cert)
|
|
@@ -387,12 +391,12 @@ describe SSLTest do
|
|
|
387
391
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/google_com_client.pem')))
|
|
388
392
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/google_com_ca_bundle.pem')))
|
|
389
393
|
|
|
390
|
-
# Disable CRL
|
|
391
|
-
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])
|
|
392
396
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
393
397
|
|
|
394
398
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
395
|
-
expect(error).to eq ("Revocation test couldn't be performed:
|
|
399
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
|
|
396
400
|
expect(valid).to eq(true)
|
|
397
401
|
expect(cert).to eq(cert)
|
|
398
402
|
end
|
|
@@ -401,9 +405,9 @@ describe SSLTest do
|
|
|
401
405
|
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_client.pem')))
|
|
402
406
|
ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_demarches-simplifiees_fr_ca_bundle.pem')))
|
|
403
407
|
|
|
404
|
-
# CRL
|
|
408
|
+
# Disable OCSP so the CRL performs the revocation check for both certs
|
|
409
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
|
|
405
410
|
expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
|
|
406
|
-
expect(SSLTest).not_to receive(:test_ocsp_revocation)
|
|
407
411
|
|
|
408
412
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
409
413
|
expect(error).to be_nil
|
|
@@ -420,18 +424,18 @@ describe SSLTest do
|
|
|
420
424
|
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
|
421
425
|
|
|
422
426
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
423
|
-
expect(error).to eq ("Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension
|
|
427
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
|
|
424
428
|
expect(valid).to eq(true)
|
|
425
429
|
expect(cert).to eq(cert)
|
|
426
430
|
|
|
427
431
|
end
|
|
428
432
|
|
|
429
|
-
it "works with OCSP for first cert and CRL for intermediate (
|
|
433
|
+
it "works with OCSP for first cert and CRL for intermediate (Google)" do
|
|
430
434
|
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
|
431
435
|
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
|
432
436
|
|
|
433
|
-
cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/
|
|
434
|
-
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')))
|
|
435
439
|
|
|
436
440
|
valid, error, cert = SSLTest.test_cert(cert, ca_bundle)
|
|
437
441
|
expect(error).to be_nil
|
|
@@ -470,8 +474,7 @@ describe SSLTest do
|
|
|
470
474
|
expect(valid).to eq(true)
|
|
471
475
|
expect(cert).to eq(cert)
|
|
472
476
|
|
|
473
|
-
|
|
474
|
-
expect($proxy).to have_received(:do_GET).twice
|
|
477
|
+
expect($proxy).to have_received(:do_GET).once
|
|
475
478
|
end
|
|
476
479
|
end
|
|
477
480
|
|
|
@@ -488,4 +491,50 @@ describe SSLTest do
|
|
|
488
491
|
end
|
|
489
492
|
end
|
|
490
493
|
end
|
|
494
|
+
|
|
495
|
+
describe '.revocation_order' do # no network: dispatch logic is stubbed
|
|
496
|
+
after { SSLTest.revocation_order = %i[ocsp crl] } # reset to the default
|
|
497
|
+
|
|
498
|
+
let(:cert) { OpenSSL::X509::Certificate.new }
|
|
499
|
+
let(:issuer) { OpenSSL::X509::Certificate.new }
|
|
500
|
+
let(:chain) { [cert, issuer] }
|
|
501
|
+
|
|
502
|
+
it "defaults to OCSP first" do
|
|
503
|
+
expect(SSLTest.revocation_order).to eq(%i[ocsp crl])
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
it "validates the value" do
|
|
507
|
+
expect { SSLTest.revocation_order = %i[crl] }.to raise_error(ArgumentError)
|
|
508
|
+
expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
it "checks OCSP first by default, falling back to CRL on error" do
|
|
512
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return([false, "OCSP boom", nil])
|
|
513
|
+
expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return(:crl_ok)
|
|
514
|
+
result = SSLTest.send(:test_chain_revocation, chain)
|
|
515
|
+
expect(result).to eq([false, nil, nil])
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
it "checks CRL first when configured, falling back to OCSP on error" do
|
|
519
|
+
SSLTest.revocation_order = %i[crl ocsp]
|
|
520
|
+
expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return([false, "CRL boom", nil])
|
|
521
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return(:ocsp_ok)
|
|
522
|
+
result = SSLTest.send(:test_chain_revocation, chain)
|
|
523
|
+
expect(result).to eq([false, nil, nil])
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
it "does not try the second method when the first one passes" do
|
|
527
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
|
|
528
|
+
expect(SSLTest).not_to receive(:test_crl_revocation)
|
|
529
|
+
expect(SSLTest.send(:test_chain_revocation, chain)).to eq([false, nil, nil])
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
it "combines error messages in the configured order when all methods fail" do
|
|
533
|
+
SSLTest.revocation_order = %i[ocsp crl]
|
|
534
|
+
allow(SSLTest).to receive(:test_ocsp_revocation).and_return([false, "OCSP boom", nil])
|
|
535
|
+
allow(SSLTest).to receive(:test_crl_revocation).and_return([false, "CRL boom", nil])
|
|
536
|
+
_revoked, message, _date = SSLTest.send(:test_chain_revocation, chain)
|
|
537
|
+
expect(message).to eq("OCSP: OCSP boom, CRL: CRL boom")
|
|
538
|
+
end
|
|
539
|
+
end
|
|
491
540
|
end
|
data/ssl-test.gemspec
CHANGED
|
@@ -12,6 +12,8 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.homepage = "https://github.com/jarthod/ssl-test"
|
|
13
13
|
spec.license = "MIT"
|
|
14
14
|
|
|
15
|
+
spec.required_ruby_version = ">= 3.1"
|
|
16
|
+
|
|
15
17
|
spec.files = `git ls-files -z`.split("\x0")
|
|
16
18
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
17
19
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ssl-test
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
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
|
- - ">="
|