ssl-test 1.6.0 → 2.0.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: fa4bf88f0998469b7b82910dcef9f53cc6a64827c5aba691591138b78d33877e
4
- data.tar.gz: 1666b7fd1b40b3a611d04521041ad64a5a0c6536c3d2fafb3b412ce184c371d5
3
+ metadata.gz: 073a878a42dbba9b30e7c69ec4b435cd8f2c607e59a6b894099af73ea60526f8
4
+ data.tar.gz: 709b2f097b7d8a61922f2cfcee0cb6651046c57854dea0b3dc17c8367b7b856a
5
5
  SHA512:
6
- metadata.gz: a40f9e0a498e0ede200cfd6382c24af3b1ab39163b4ac040b4817dff08dc7c320136b3e8516eb5a4fd4f443083da54cf5a822a5736a4795d93de8c7e2581e38b
7
- data.tar.gz: c51a2d657031506b792960ccd3975521e8cfe5f9c5ffdfd06a7d48a6eacadef63811c4467acc34145d17022de8c3f92ac3a5ccf56316f4766b5ccbefe5fbc9a9
6
+ metadata.gz: 150c1d97a77c0a37c71a180d7cd643f3a4264280f65261e2cb830b184fed00cb60458bf4d27ce1eb52225db506398d2e1480bf69d5a7d6ffc4ee7e5ad288a5b1
7
+ data.tar.gz: '08f66249c1b7d951b71283589d957b6bfe4451934ff155af255e4e31a5f77d0aa27bcd1dcc9cee1dcb0b1ddc450618d97c9a445c5067990009b14ad616933128'
@@ -3,12 +3,24 @@ 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']
10
+ services:
11
+ redis:
12
+ image: redis
13
+ ports: ['6379:6379']
14
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
15
+ memcached:
16
+ image: memcached
17
+ ports: ['11211:11211']
6
18
  steps:
7
19
  - uses: actions/checkout@v2
8
20
  - name: Set up Ruby
9
21
  uses: ruby/setup-ruby@v1
10
22
  with:
11
- ruby-version: '3.1'
23
+ ruby-version: ${{ matrix.ruby-version }}
12
24
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
13
25
  - name: Run specs
14
26
  run: |
data/README.md CHANGED
@@ -70,6 +70,13 @@ cert # => #<OpenSSL::X509::Certificate...>
70
70
 
71
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).
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):
74
+
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
78
+ ```
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
 
75
82
  ```ruby
@@ -100,16 +107,42 @@ After that it fetches the [CRL](https://en.wikipedia.org/wiki/Certificate_revoca
100
107
 
101
108
  ### Caching
102
109
 
103
- OCSP and CRL responses are cached in memory, which makes subsequent testing faster and more robust (avoids network error and throttling) but be careful about memory usage if you try to validate millions of certificates in a row.
110
+ OCSP and CRL responses are cached, which makes subsequent testing faster and more robust (avoids network error and throttling).
104
111
 
105
112
  About the caching duration:
106
113
  - OCSP responses are cached until their "next_update" indicated inside the repsonse
107
114
  - OCSP errors are cached for 5 minutes
108
115
  - CRL responses are cached for 1 hour
109
116
 
110
- CRL responses can be big so when they expires they are re-validated with the server using HTTP caching headers when available (`Etag` & `Last-Modified`) to avoid downloading the list again if it didn't change.
117
+ CRL responses can be big so when they expires they are re-validated with the server using HTTP caching headers when available (`Etag` & `Last-Modified`) to avoid downloading the list again if it didn't change. The cached body is therefore kept in the backend for a longer retention period (~4 days, refreshed on each use) so it's still around to revalidate against; unused lists are dropped after that.
118
+
119
+ #### Cache backend
111
120
 
112
- You can check the size of the cache with `SSLTest.cache_size`, which returns:
121
+ The cache backend is configurable. By default SSLTest uses a simple in-process store (`SSLTest::MemoryStore`). To share the cache across processes and get compression, assign any object implementing the `Rails.cache`-style API (`read`, `write(key, value, expires_in:)`, `delete`):
122
+
123
+ ```ruby
124
+ SSLTest.cache = Rails.cache # shared + compressed (e.g. memcache via Dalli)
125
+ SSLTest.cache = SSLTest::MemoryStore.new # the default in-process store
126
+ SSLTest.cache = MyCustomStore.new # anything responding to read/write/delete
127
+ ```
128
+
129
+ The default in-process store is per-process and unbounded, so be careful about memory usage if you try to validate millions of certificates in a row (the OCSP cache is keyed by certificate serial). Using a shared store like `Rails.cache` with memcache avoids this and shares the cache across processes.
130
+
131
+ If you want a bounded/compressed in-process cache without pulling in `Rails.cache`, the API is intentionally compatible with `ActiveSupport::Cache::MemoryStore`, which you can drop in directly:
132
+
133
+ ```ruby
134
+ require "active_support/cache"
135
+ SSLTest.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes, compress: true)
136
+ ```
137
+
138
+ (It auto-prunes when it exceeds `size`, unlike the built-in store. Note the introspection helpers below are specific to `SSLTest::MemoryStore`.)
139
+
140
+ > **Using memcached (Dalli)?** CRL lists can be large (commonly several MB, up to ~20MB for busy CAs). memcached rejects values over its max item size — 1MB by default — which Dalli surfaces as `Dalli::ValueOverMaxSize` (logged and skipped by `ActiveSupport::Cache::MemCacheStore`, so the test still passes but the list isn't cached and gets re-downloaded every time). To actually cache big CRLs, raise the limit on **both** sides to at least 64MB:
141
+ >
142
+ > - memcached server: start it with `-I 64m`
143
+ > - Dalli client: `Dalli::Client.new(servers, value_max_bytes: 64 * 1024 * 1024)`, or via Rails: `config.cache_store = :mem_cache_store, servers, { value_max_bytes: 64.megabytes }`
144
+
145
+ You can check the size of the **built-in** store with `SSLTest.cache.size`, which returns:
113
146
 
114
147
  ```ruby
115
148
  {
@@ -125,7 +158,9 @@ You can check the size of the cache with `SSLTest.cache_size`, which returns:
125
158
  }
126
159
  ```
127
160
 
128
- You can also flush the cache using `SSLTest.flush_cache` if you want (not recommended)
161
+ You can also flush it using `SSLTest.cache.clear` if you want (not recommended).
162
+
163
+ `size` is specific to the built-in `MemoryStore`; other backends won't respond to it. (The module-level `SSLTest.cache_size` and `SSLTest.flush_cache` from previous versions were **removed in 2.0** — use `SSLTest.cache.size` / `SSLTest.cache.clear` instead.)
129
164
 
130
165
  ### Logging
131
166
 
@@ -167,6 +202,8 @@ But also **revoked certs** like most browsers (not handled by `curl`)
167
202
 
168
203
  See also github releases: https://github.com/jarthod/ssl-test/releases
169
204
 
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)
170
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
171
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)
172
209
  * 1.4.1 - 2022-10-24: Add support for "tcps://" scheme
data/lib/ssl-test/crl.rb CHANGED
@@ -1,6 +1,13 @@
1
1
  module SSLTest
2
2
  module CRL
3
3
  CRL_CACHE_DURATION = 3600 # 1 hour
4
+ # How long a CRL entry is kept in the backend before being dropped if it's no
5
+ # longer used. This is much longer than CRL_CACHE_DURATION so the cached body
6
+ # and caching headers survive past the revalidation window (for cheap 304s),
7
+ # but bounded so unused lists don't pile up forever in a shared/long-lived
8
+ # backend (e.g. memcache). It's refreshed on every fetch (200/304), so
9
+ # actively-used entries never expire from this.
10
+ CRL_CACHE_RETENTION = 100 * CRL_CACHE_DURATION # ~4 days
4
11
 
5
12
  # A note about caching:
6
13
  # I choose to only cache the raw HTTP body here (and not the parsed list or better a hash
@@ -40,11 +47,19 @@ module SSLTest
40
47
  response = OpenSSL::X509::CRL.new http_response
41
48
  return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)
42
49
 
50
+ # Fast path: scan the raw response for the cert's serial encoded as DER.
51
+ # In most case (not revoked) this lets us skip response.revoked, which
52
+ # instantiate the *entire* revocation list as Ruby objects (>1M objects for busy CAs)
53
+ serial_der = OpenSSL::ASN1::Integer.new(cert.serial).to_der
54
+ return :crl_ok unless response.to_der.include?(serial_der)
55
+
56
+ # The serial's bytes appear (a real hit, or a rare collision):
57
+ # confirm authoritatively and pull the reason/date. The costly revoked-list
58
+ # materialisation only happens here, i.e. for actually-revoked certs.
43
59
  revoked = response.revoked.find { |r| r.serial == cert.serial }
44
60
  if revoked
45
61
  reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
46
62
  return [true, reason || "Unknown reason", revoked.time]
47
- else
48
63
  end
49
64
 
50
65
  :crl_ok
@@ -54,10 +69,14 @@ module SSLTest
54
69
  def follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5, proxy_host: nil, proxy_port: nil)
55
70
  return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
56
71
 
57
- # Return file from cache if not expired
58
- @crl_response_cache ||= {}
59
- cache_entry = @crl_response_cache[uri]
60
- return [cache_entry[:body], nil] if cache_entry && cache_entry.fetch(:expires) > Time.now
72
+ # Return file from cache if not expired.
73
+ # CRL entries are kept in the backend for CRL_CACHE_RETENTION (much longer
74
+ # than CRL_CACHE_DURATION) so the cached body + caching headers survive past
75
+ # the freshness window and can be revalidated cheaply with a conditional
76
+ # request (304). We track our own freshness window with the :expires field.
77
+ cache_key = "#{CACHE_NAMESPACE}/crl/#{uri}"
78
+ cache_entry = cache.read(cache_key)
79
+ return [cache_entry[:body], nil] if cache_entry && cache_entry[:expires] > Time.now
61
80
 
62
81
  @logger&.debug { "SSLTest + CRL: fetch URI #{uri}" }
63
82
  path = uri.path == "" ? "/" : uri.path
@@ -66,30 +85,31 @@ module SSLTest
66
85
  http.read_timeout = read_timeout
67
86
 
68
87
  req = Net::HTTP::Get.new(path)
69
- # Include conditional caching headers from cache to save bandwidth if list didn't change (304)
70
- if etag = cache_entry&.fetch(:etag)
71
- req["If-None-Match"] = etag
72
- elsif last_mod = cache_entry&.fetch(:last_mod)
73
- req["If-Modified-Since"] = last_mod
74
- end
88
+ # Include conditional caching headers from cache to save bandwidth if the
89
+ # list didn't change (304). Send both validators when present: some
90
+ # CDN-backed CAs (e.g. DigiCert) serve per-node ETags they won't honor via
91
+ # If-None-Match but will revalidate via If-Modified-Since, so sending only
92
+ # the ETag defeats the 304 and re-downloads the whole list every time.
93
+ req["If-None-Match"] = cache_entry[:etag] if cache_entry&.[](:etag)
94
+ req["If-Modified-Since"] = cache_entry[:last_mod] if cache_entry&.[](:last_mod)
75
95
  http_response = http.request(req)
76
96
  case http_response
77
97
  when Net::HTTPNotModified
78
98
  # No changes, bump cache expiration time and return cached body
79
99
  @logger&.debug { "SSLTest + CRL: 304 Not Modified" }
80
- @crl_response_cache[uri][:expires] = Time.now + CRL_CACHE_DURATION
100
+ cache.write(cache_key, cache_entry.merge(expires: Time.now + CRL_CACHE_DURATION), expires_in: CRL_CACHE_RETENTION)
81
101
  [cache_entry[:body], nil]
82
102
  when Net::HTTPSuccess
83
103
  # Success, update (or add to) cache and return frech body
84
104
  @logger&.debug { "SSLTest + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
85
- @logger&.warn { "SSLTest + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
105
+ @logger&.warn { "SSLTest + CRL: Warning: massive file size (#{http_response.body.bytesize} bytes)" } if http_response.body.bytesize > 1024**2 # 1MB
86
106
  @logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
87
- @crl_response_cache[uri] = {
107
+ cache.write(cache_key, {
88
108
  body: http_response.body,
89
109
  expires: Time.now + CRL_CACHE_DURATION,
90
110
  etag: http_response["Etag"],
91
111
  last_mod: http_response["Last-Modified"]
92
- }
112
+ }, expires_in: CRL_CACHE_RETENTION)
93
113
  [http_response.body, nil]
94
114
  when Net::HTTPRedirection
95
115
  follow_crl_redirects(URI(http_response["location"]), open_timeout: open_timeout, read_timeout: read_timeout, proxy_host: proxy_host, proxy_port: proxy_port, redirection_limit: redirection_limit - 1)
@@ -0,0 +1,81 @@
1
+ module SSLTest
2
+ # A tiny in-process cache store used as the default backend. It mirrors the
3
+ # small subset of the ActiveSupport::Cache / Rails.cache API that SSLTest relies
4
+ # on (read/write/delete) so they're interchangeable (assign Rails.cache via
5
+ # SSLTest.cache= to share/compress across processes). Access is guarded by a
6
+ # Mutex because SSLTest is typically used from threaded servers (e.g. Puma).
7
+ #
8
+ # Unlike a shared/compressed backend (memcache via Dalli), this store is
9
+ # per-process, uncompressed and unbounded, so be careful about memory usage if
10
+ # you validate millions of certificates in a row (the OCSP cache is keyed by
11
+ # certificate serial). For those workloads, configure SSLTest.cache to a shared
12
+ # store instead.
13
+ class MemoryStore
14
+ def initialize
15
+ @data = {}
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def read(key)
20
+ @mutex.synchronize do
21
+ entry = @data[key]
22
+ next nil unless entry
23
+ if entry[:expires_at] && entry[:expires_at] <= Time.now
24
+ @data.delete(key)
25
+ next nil
26
+ end
27
+ entry[:value]
28
+ end
29
+ end
30
+
31
+ def write(key, value, expires_in: nil)
32
+ @mutex.synchronize do
33
+ @data[key] = { value: value, expires_at: expires_in && Time.now + expires_in }
34
+ end
35
+ value
36
+ end
37
+
38
+ def delete(key)
39
+ @mutex.synchronize { @data.delete(key) }
40
+ end
41
+
42
+ def clear
43
+ @mutex.synchronize { @data.clear }
44
+ end
45
+
46
+ # Yields [key, value] for every entry that hasn't expired.
47
+ def each
48
+ return enum_for(:each) unless block_given?
49
+ now = Time.now
50
+ @mutex.synchronize { @data.dup }.each do |key, entry|
51
+ next if entry[:expires_at] && entry[:expires_at] <= now
52
+ yield key, entry[:value]
53
+ end
54
+ end
55
+
56
+ # Returns a breakdown of the cached SSLTest entries (CRL lists and OCSP
57
+ # responses/errors) with approximate byte sizes, mainly useful for monitoring
58
+ # memory usage. Specific to ssl-test's key namespace.
59
+ def size
60
+ crl_lists = ocsp_responses = ocsp_errors = 0
61
+ crl_bytes = ocsp_bytes = 0
62
+ each do |key, value|
63
+ case key
64
+ when %r{\A#{CACHE_NAMESPACE}/crl/}
65
+ crl_lists += 1
66
+ crl_bytes += ObjectSize.size(value)
67
+ when %r{\A#{CACHE_NAMESPACE}/ocsp-error/}
68
+ ocsp_errors += 1
69
+ ocsp_bytes += ObjectSize.size(value)
70
+ when %r{\A#{CACHE_NAMESPACE}/ocsp/}
71
+ ocsp_responses += 1
72
+ ocsp_bytes += ObjectSize.size(value)
73
+ end
74
+ end
75
+ {
76
+ crl: { lists: crl_lists, bytes: crl_bytes },
77
+ ocsp: { responses: ocsp_responses, errors: ocsp_errors, bytes: ocsp_bytes }
78
+ }
79
+ end
80
+ end
81
+ end
data/lib/ssl-test/ocsp.rb CHANGED
@@ -5,15 +5,18 @@ module SSLTest
5
5
  private
6
6
 
7
7
  def test_ocsp_revocation cert, issuer:, chain:, **options
8
- @ocsp_response_cache ||= {}
9
- @ocsp_request_error_cache ||= {}
10
-
11
8
  unicity_key = "#{cert.issuer}/#{cert.serial}"
9
+ error_cache_key = "#{CACHE_NAMESPACE}/ocsp-error/#{unicity_key}"
10
+ response_cache_key = "#{CACHE_NAMESPACE}/ocsp/#{unicity_key}"
12
11
 
13
- current_request_error_cache = @ocsp_request_error_cache[unicity_key]
14
- return current_request_error_cache[:error] if current_request_error_cache && Time.now <= current_request_error_cache[:expires]
12
+ # Expiry is handled by the cache backend (expires_in), so a cached value is
13
+ # always still valid: a present error means we recently failed, a present
14
+ # response means it hasn't reached its next_update yet.
15
+ cached_error = cache.read(error_cache_key)
16
+ return cached_error if cached_error
15
17
 
16
- if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
18
+ ocsp_response = cache.read(response_cache_key)
19
+ if ocsp_response.nil?
17
20
  authority_info_access = cert.extensions.find do |extension|
18
21
  extension.oid == "authorityInfoAccess"
19
22
  end
@@ -57,11 +60,13 @@ module SSLTest
57
60
 
58
61
  return ocsp_soft_fail_return("Serial check failed (URI: #{ocsp_uri})", unicity_key) unless response_certificate_id.serial == certificate_id.serial
59
62
 
60
- @ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
63
+ ocsp_response = { status: status, reason: reason, revocation_time: revocation_time }
64
+ # Cache until the response's next_update. If it's already past (or
65
+ # missing), skip caching and just use the fresh result for this call.
66
+ ttl = next_update && next_update - Time.now
67
+ cache.write(response_cache_key, ocsp_response, expires_in: ttl) if ttl && ttl > 0
61
68
  end
62
69
 
63
- ocsp_response = @ocsp_response_cache[unicity_key]
64
-
65
70
  return [true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
66
71
  :ocsp_ok
67
72
  end
@@ -135,7 +140,7 @@ module SSLTest
135
140
 
136
141
  def ocsp_soft_fail_return(reason, unicity_key = nil)
137
142
  error = [false, reason, nil]
138
- @ocsp_request_error_cache[unicity_key] = { error: error, expires: Time.now + ERROR_CACHE_DURATION } if unicity_key
143
+ cache.write("#{CACHE_NAMESPACE}/ocsp-error/#{unicity_key}", error, expires_in: ERROR_CACHE_DURATION) if unicity_key
139
144
  error
140
145
  end
141
146
  end
data/lib/ssl-test.rb CHANGED
@@ -3,6 +3,7 @@ require "net/https"
3
3
  require "openssl"
4
4
  require "uri"
5
5
  require "ssl-test/object_size"
6
+ require "ssl-test/memory_store"
6
7
  require "ssl-test/ocsp"
7
8
  require "ssl-test/crl"
8
9
 
@@ -10,7 +11,11 @@ module SSLTest
10
11
  extend OCSP
11
12
  extend CRL
12
13
 
13
- VERSION = -"1.6.0"
14
+ VERSION = -"2.0.1"
15
+
16
+ # Prefix for all cache keys so SSLTest entries coexist cleanly inside a shared
17
+ # cache (e.g. Rails.cache).
18
+ CACHE_NAMESPACE = -"ssl-test"
14
19
 
15
20
  class << self
16
21
  def test_url url, open_timeout: 5, read_timeout: 5, proxy_host: nil, proxy_port: nil, redirection_limit: 5
@@ -79,30 +84,53 @@ module SSLTest
79
84
  end
80
85
  end
81
86
 
87
+ # The cache backend used to store CRL and OCSP responses. Defaults to an
88
+ # in-process MemoryStore. To share the cache across processes (and get
89
+ # compression), assign Rails.cache (or any object responding to the
90
+ # Rails.cache-style API: read/write/delete), e.g. `SSLTest.cache = Rails.cache`.
91
+ def cache
92
+ @cache ||= MemoryStore.new
93
+ end
94
+
95
+ def cache= store
96
+ @cache = store
97
+ end
98
+
99
+ # Removed in 2.0: introspection now lives on the cache store. With the
100
+ # built-in MemoryStore use SSLTest.cache.size; other backends (e.g. memcache)
101
+ # can't be enumerated.
82
102
  def cache_size
83
- {
84
- crl: {
85
- lists: @crl_response_cache&.size || 0,
86
- bytes: ObjectSize.size(@crl_response_cache)
87
- },
88
- ocsp: {
89
- responses: @ocsp_response_cache&.size || 0,
90
- errors: @ocsp_request_error_cache&.size || 0,
91
- bytes: ObjectSize.size(@ocsp_response_cache) + ObjectSize.size(@ocsp_request_error_cache)
92
- }
93
- }
103
+ raise NoMethodError, "SSLTest.cache_size was removed in 2.0; use SSLTest.cache.size instead (available on the built-in SSLTest::MemoryStore)."
94
104
  end
95
105
 
106
+ # Removed in 2.0: clearing now lives on the cache store. With the built-in
107
+ # MemoryStore use SSLTest.cache.clear (note: calling clear on a shared backend
108
+ # like Rails.cache would wipe unrelated entries).
96
109
  def flush_cache
97
- @crl_response_cache = {}
98
- @ocsp_response_cache = {}
99
- @ocsp_request_error_cache = {}
110
+ raise NoMethodError, "SSLTest.flush_cache was removed in 2.0; use SSLTest.cache.clear instead."
100
111
  end
101
112
 
102
113
  def logger= logger
103
114
  @logger = logger
104
115
  end
105
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 CRL first (since 1.6) to reduce the
121
+ # revocation propagation delay. Set to %i[ocsp crl] to check OCSP first.
122
+ def revocation_order
123
+ @revocation_order ||= %i[crl ocsp]
124
+ end
125
+
126
+ def revocation_order= order
127
+ order = Array(order).map { |m| m.to_sym }
128
+ unless order.sort == %i[crl ocsp]
129
+ raise ArgumentError, "SSLTest.revocation_order must be %i[crl ocsp] or %i[ocsp crl], got #{order.inspect}"
130
+ end
131
+ @revocation_order = order
132
+ end
133
+
106
134
  private
107
135
 
108
136
  def revocation_message(revoked, revocation_date, message)
@@ -136,26 +164,37 @@ module SSLTest
136
164
  chain[0..-2].each_with_index do |cert, i|
137
165
  @logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
138
166
 
139
- # Try with CRL first
140
- crl_result = test_crl_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
141
- @logger&.debug { "SSLTest + CRL: #{crl_result}" }
142
- next if crl_result == :crl_ok # passed, go to next cert
143
- return crl_result if crl_result[0] == true # revoked
144
-
145
- # Otherwise it means there was an error so let's try with OCSP instead
146
- ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
147
- @logger&.debug { "SSLTest + OCSP: #{ocsp_result}" }
148
- next if ocsp_result == :ocsp_ok # passed, go to next cert
149
- return ocsp_result if ocsp_result[0] == true # revoked
167
+ # Try each revocation method in the configured order, falling back to the
168
+ # next one only when the current method errors out.
169
+ errors = {}
170
+ passed = false
171
+ revocation_order.each do |method|
172
+ result = test_revocation(method, cert, issuer: chain[i + 1], chain: chain, **options)
173
+ @logger&.debug { "SSLTest + #{method.to_s.upcase}: #{result}" }
174
+ if result == :"#{method}_ok" # passed, go to next cert
175
+ passed = true
176
+ break
177
+ end
178
+ return result if result[0] == true # revoked
179
+ errors[method] = result[1] # errored, try the next method
180
+ end
181
+ next if passed
150
182
 
151
- # If both method failed, return a soft fail with a combination of both error messages
152
- return [false, "CRL: #{crl_result[1]}, OCSP: #{ocsp_result[1]}", nil]
183
+ # If all methods failed, return a soft fail with a combination of the error messages
184
+ return [false, errors.map { |method, message| "#{method.to_s.upcase}: #{message}" }.join(", "), nil]
153
185
  end
154
186
 
155
187
  # If all test passed, the certificate is not revoked
156
188
  [false, nil, nil]
157
189
  end
158
190
 
191
+ def test_revocation method, cert, **options
192
+ case method
193
+ when :crl then test_crl_revocation(cert, **options)
194
+ when :ocsp then test_ocsp_revocation(cert, **options)
195
+ end
196
+ end
197
+
159
198
  def cert_field_to_hash field
160
199
  field.to_a.each.with_object({}) do |v, h|
161
200
  v = v.to_a
@@ -0,0 +1,103 @@
1
+ require "ssl-test"
2
+ require "active_support"
3
+ require "active_support/cache"
4
+ require "tmpdir"
5
+
6
+ # Verifies the cache backends people are likely to plug into SSLTest.cache (the
7
+ # classic Rails/ActiveSupport stores) satisfy the read / write / expiration
8
+ # contract SSLTest relies on, including (de)serialization of the value shapes it
9
+ # stores: Hashes containing Strings (incl. binary CRL bodies), Times, Integers
10
+ # and nils, plus Arrays (OCSP errors).
11
+ #
12
+ # Stores backed by an external server (MemCacheStore, RedisCacheStore) or an
13
+ # extra gem are skipped when unavailable, so the suite stays green locally; CI
14
+ # provides the servers (see .github/workflows/ruby.yml) so they actually run.
15
+ describe "ActiveSupport cache backend compatibility" do
16
+ # Representative of what SSLTest caches: a CRL entry (binary body + Time) and
17
+ # an OCSP error entry (an Array). Fixed Time so serialization round-trips are
18
+ # deterministic.
19
+ let(:crl_entry) do
20
+ { body: ("\x30\x82\x01\x02".b * 50), expires: Time.utc(2030, 1, 1, 12), etag: 'W/"abc123"', last_mod: nil }
21
+ end
22
+ let(:ocsp_error) { [false, "Request failed (URI: http://ocsp.example.com)", nil] }
23
+
24
+ # Stores that actually persist values (NullStore intentionally doesn't).
25
+ CACHING_STORES = %w[MemoryStore FileStore MemCacheStore RedisCacheStore]
26
+
27
+ def build_store(name)
28
+ case name
29
+ when "MemoryStore"
30
+ ActiveSupport::Cache::MemoryStore.new
31
+ when "FileStore"
32
+ ActiveSupport::Cache::FileStore.new(Dir.mktmpdir("ssl-test-cache"))
33
+ when "NullStore"
34
+ ActiveSupport::Cache::NullStore.new
35
+ when "MemCacheStore"
36
+ require "dalli"
37
+ ActiveSupport::Cache::MemCacheStore.new(ENV.fetch("MEMCACHE_SERVERS", "127.0.0.1:11211"))
38
+ when "RedisCacheStore"
39
+ require "redis"
40
+ ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/15"))
41
+ end
42
+ end
43
+
44
+ around do |example|
45
+ previous = SSLTest.cache
46
+ example.run
47
+ ensure
48
+ SSLTest.cache = previous
49
+ end
50
+
51
+ (CACHING_STORES + %w[NullStore]).each do |name|
52
+ context name do
53
+ before do
54
+ begin
55
+ SSLTest.cache = build_store(name)
56
+ rescue LoadError => e
57
+ skip "#{name} unavailable: #{e.message}"
58
+ end
59
+
60
+ # For server-backed stores, ActiveSupport silently treats a missing
61
+ # server as a cache miss; probe so we skip (rather than fail) when the
62
+ # server isn't running.
63
+ if CACHING_STORES.include?(name)
64
+ SSLTest.cache.write("ssl-test/probe", "ok", expires_in: 60)
65
+ skip "#{name} server not reachable" unless SSLTest.cache.read("ssl-test/probe") == "ok"
66
+ end
67
+ end
68
+
69
+ if name == "NullStore"
70
+ it "acts as a no-op (the gem still works, just without caching)" do
71
+ SSLTest.cache.write("ssl-test/crl/x", crl_entry, expires_in: nil)
72
+ expect(SSLTest.cache.read("ssl-test/crl/x")).to be_nil
73
+ end
74
+ else
75
+ it "round-trips a CRL entry (binary body + Time serialization)" do
76
+ SSLTest.cache.write("ssl-test/crl/x", crl_entry, expires_in: 100 * 3600)
77
+ expect(SSLTest.cache.read("ssl-test/crl/x")).to eq(crl_entry)
78
+ end
79
+
80
+ it "round-trips an OCSP error entry (Array serialization)" do
81
+ SSLTest.cache.write("ssl-test/ocsp-error/y", ocsp_error, expires_in: 300)
82
+ expect(SSLTest.cache.read("ssl-test/ocsp-error/y")).to eq(ocsp_error)
83
+ end
84
+
85
+ it "returns nil for a missing key" do
86
+ expect(SSLTest.cache.read("ssl-test/ocsp/missing")).to be_nil
87
+ end
88
+
89
+ it "persists entries written with no expiry (expires_in: nil)" do
90
+ SSLTest.cache.write("ssl-test/crl/persist", crl_entry, expires_in: nil)
91
+ expect(SSLTest.cache.read("ssl-test/crl/persist")).to eq(crl_entry)
92
+ end
93
+
94
+ it "honors expires_in" do
95
+ SSLTest.cache.write("ssl-test/ocsp/z", { status: 0 }, expires_in: 0.1)
96
+ expect(SSLTest.cache.read("ssl-test/ocsp/z")).to eq({ status: 0 })
97
+ sleep 0.2
98
+ expect(SSLTest.cache.read("ssl-test/ocsp/z")).to be_nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,80 @@
1
+ require "ssl-test"
2
+ require "rspec/retry" # the cache.size examples below hit live CRL/OCSP endpoints
3
+
4
+ describe SSLTest::MemoryStore do
5
+ subject(:store) { described_class.new }
6
+
7
+ it "round-trips written values" do
8
+ store.write("k", "v")
9
+ expect(store.read("k")).to eq("v")
10
+ end
11
+
12
+ it "returns nil for missing keys" do
13
+ expect(store.read("missing")).to be_nil
14
+ end
15
+
16
+ it "expires entries after expires_in" do
17
+ store.write("k", "v", expires_in: -1) # already expired
18
+ expect(store.read("k")).to be_nil
19
+ end
20
+
21
+ it "keeps entries with no expiry" do
22
+ store.write("k", "v", expires_in: nil)
23
+ expect(store.read("k")).to eq("v")
24
+ end
25
+
26
+ it "deletes and clears entries" do
27
+ store.write("a", 1)
28
+ store.write("b", 2)
29
+ store.delete("a")
30
+ expect(store.read("a")).to be_nil
31
+ expect(store.read("b")).to eq(2)
32
+ store.clear
33
+ expect(store.read("b")).to be_nil
34
+ end
35
+
36
+ it "iterates non-expired entries with #each" do
37
+ store.write("live", 1)
38
+ store.write("dead", 2, expires_in: -1)
39
+ expect(store.each.to_a).to eq([["live", 1]])
40
+ end
41
+
42
+ it "#size reports a CRL/OCSP breakdown" do
43
+ store.write("ssl-test/crl/http://example.com/x.crl", "body")
44
+ store.write("ssl-test/ocsp/issuer/1", { status: 0 })
45
+ store.write("ssl-test/ocsp-error/issuer/2", [false, "err", nil])
46
+ store.write("unrelated/key", "ignored")
47
+ expect(store.size).to match({
48
+ crl: { lists: 1, bytes: be > 0 },
49
+ ocsp: { responses: 1, errors: 1, bytes: be > 0 }
50
+ })
51
+ end
52
+ end
53
+
54
+ # #size as reported through the default store after real CRL/OCSP fetches.
55
+ describe "SSLTest.cache.size", retry: 5 do # examples hit live CRL/OCSP endpoints
56
+ before { SSLTest.cache.clear }
57
+
58
+ it "returns 0 by default" do
59
+ expect(SSLTest.cache.size).to eq({
60
+ crl: { bytes: 0, lists: 0 },
61
+ ocsp: { bytes: 0, errors: 0, responses: 0 }
62
+ })
63
+ end
64
+
65
+ it "returns CRL cache size properly" do
66
+ SSLTest.send(:follow_crl_redirects, URI("http://crl.certigna.fr/certigna.crl")) # 1.1k
67
+ SSLTest.send(:follow_crl_redirects, URI("http://crl3.digicert.com/DigiCertTLSHybridECCSHA3842020CA1-1.crl")) # 26k
68
+ expect(SSLTest.cache.size[:crl][:lists]).to eq(2)
69
+ expect(SSLTest.cache.size[:crl][:bytes]).to be > 2000
70
+ end
71
+
72
+ it "returns OCSP cache size properly" do
73
+ SSLTest.test("https://github.com")
74
+ expect(SSLTest.cache.size[:ocsp][:responses]).to eq(1)
75
+ expect(SSLTest.cache.size[:ocsp][:errors]).to eq(0)
76
+ expect(SSLTest.cache.size[:ocsp][:bytes]).to be > 0
77
+ expect(SSLTest.cache.size[:crl][:lists]).to eq(1)
78
+ expect(SSLTest.cache.size[:crl][:bytes]).to be > 100
79
+ end
80
+ end
@@ -12,22 +12,21 @@ RSpec.configure do |config|
12
12
  # The error/revocation examples below hit several public TLS test endpoints
13
13
  # (badssl.com, testserver.host, ssl.com) which intermittently reset connections
14
14
  # under load. They're spread across a few providers to avoid hammering a single
15
- # one, and examples tagged `:retry` are re-run a few times (via rspec-retry) so
16
- # transient network blips don't fail the suite.
15
+ # one, and the network-hitting describe blocks are tagged `retry: 5` (via
16
+ # rspec-retry) so transient network blips don't fail the suite.
17
17
  config.verbose_retry = true
18
- config.display_try_failure_messages = true
19
18
  config.default_sleep_interval = 1
20
19
  end
21
20
 
22
21
  describe SSLTest do
23
- before { SSLTest.flush_cache }
22
+ before { SSLTest.cache.clear }
24
23
 
25
24
  let(:proxy_thread) { nil }
26
25
 
27
26
 
28
27
  after(:each) { proxy_thread&.kill }
29
28
 
30
- describe '.test_url' do
29
+ describe '.test_url', retry: 5 do # examples hit live TLS/CRL/OCSP endpoints
31
30
  it "returns no error on valid SNI website" do
32
31
  valid, error, cert = SSLTest.test("https://www.mycs.com")
33
32
  expect(error).to be_nil
@@ -60,35 +59,35 @@ describe SSLTest do
60
59
  expect(cert).to be_a OpenSSL::X509::Certificate
61
60
  end
62
61
 
63
- it "returns error on self signed certificate", :retry => 5 do
62
+ it "returns error on self signed certificate" do
64
63
  valid, error, cert = SSLTest.test("https://self-signed.testserver.host/")
65
64
  expect(error).to eq ("error code 18: self-signed certificate")
66
65
  expect(valid).to eq(false)
67
66
  expect(cert).to be_a OpenSSL::X509::Certificate
68
67
  end
69
68
 
70
- it "returns error on incomplete chain", :retry => 5 do
69
+ it "returns error on incomplete chain" do
71
70
  valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
72
71
  expect(error).to eq ("error code 20: unable to get local issuer certificate")
73
72
  expect(valid).to eq(false)
74
73
  expect(cert).to be_a OpenSSL::X509::Certificate
75
74
  end
76
75
 
77
- it "returns error on untrusted root", :retry => 5 do
76
+ it "returns error on untrusted root" do
78
77
  valid, error, cert = SSLTest.test("https://untrusted-root.testserver.host/")
79
78
  expect(error).to eq ("error code 19: self-signed certificate in certificate chain")
80
79
  expect(valid).to eq(false)
81
80
  expect(cert).to be_a OpenSSL::X509::Certificate
82
81
  end
83
82
 
84
- it "returns error on invalid host", :retry => 5 do
83
+ it "returns error on invalid host" do
85
84
  valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
86
85
  expect(error).to include('error code 62: hostname mismatch')
87
86
  expect(valid).to eq(false)
88
87
  expect(cert).to be_a OpenSSL::X509::Certificate
89
88
  end
90
89
 
91
- it "returns error on expired cert", :retry => 5 do
90
+ it "returns error on expired cert" do
92
91
  valid, error, cert = SSLTest.test("https://expired-rsa-dv.ssl.com/")
93
92
  expect(error).to eq ("error code 10: certificate has expired")
94
93
  expect(valid).to eq(false)
@@ -129,10 +128,12 @@ describe SSLTest do
129
128
  expect(cert).to be_a OpenSSL::X509::Certificate
130
129
  end
131
130
 
132
- it "returns error on revoked cert (CRL)", :retry => 5 do
131
+ it "returns error on revoked cert (CRL)" do
133
132
  # CRL is tried first and detects the revocation, so OCSP is never used
134
133
  expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
135
134
  expect(SSLTest).not_to receive(:test_ocsp_revocation)
135
+ # On a serial byte-match we fall back to #revoked to extract the reason/date
136
+ expect_any_instance_of(OpenSSL::X509::CRL).to receive(:revoked).and_call_original
136
137
  valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
137
138
  expect(error).to eq ("SSL certificate revoked: Key Compromise (revocation date: 2026-05-12 21:01:31 UTC)")
138
139
  expect(valid).to eq(false)
@@ -162,6 +163,9 @@ describe SSLTest do
162
163
  # CRL is tried first and succeeds for both certs, so OCSP is never used
163
164
  expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
164
165
  expect(SSLTest).not_to receive(:test_ocsp_revocation)
166
+ # Both certs are absent from their CRL, so the serial byte-search short-circuits
167
+ # and we never materialise the (potentially huge) revoked list.
168
+ expect_any_instance_of(OpenSSL::X509::CRL).not_to receive(:revoked)
165
169
  valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
166
170
  expect(error).to be_nil
167
171
  expect(valid).to eq(true)
@@ -186,7 +190,7 @@ describe SSLTest do
186
190
  expect(valid).to eq(true)
187
191
  expect(cert).to be_a OpenSSL::X509::Certificate
188
192
  # make sure both were used
189
- expect(SSLTest.cache_size).to match({
193
+ expect(SSLTest.cache.size).to match({
190
194
  crl: hash_including(lists: 1),
191
195
  ocsp: hash_including(responses: 1, errors: 0)
192
196
  })
@@ -237,35 +241,8 @@ describe SSLTest do
237
241
  end
238
242
  end
239
243
 
240
- describe '.cache_size' do
241
- before { SSLTest.flush_cache }
242
-
243
- it "returns 0 by default" do
244
- expect(SSLTest.cache_size).to eq({
245
- crl: { bytes: 0, lists: 0 },
246
- ocsp: { bytes: 0, errors: 0, responses: 0 }
247
- })
248
- end
249
-
250
- it "returns CRL cache size properly" do
251
- SSLTest.send(:follow_crl_redirects, URI("http://crl.certigna.fr/certigna.crl")) # 1.1k
252
- SSLTest.send(:follow_crl_redirects, URI("http://crl3.digicert.com/DigiCertTLSHybridECCSHA3842020CA1-1.crl")) # 26k
253
- expect(SSLTest.cache_size[:crl][:lists]).to eq(2)
254
- expect(SSLTest.cache_size[:crl][:bytes]).to be > 2000
255
- end
256
-
257
- it "returns OCSP cache size properly" do
258
- SSLTest.test("https://github.com")
259
- expect(SSLTest.cache_size[:ocsp][:responses]).to eq(1)
260
- expect(SSLTest.cache_size[:ocsp][:errors]).to eq(0)
261
- expect(SSLTest.cache_size[:ocsp][:bytes]).to be > 150
262
- expect(SSLTest.cache_size[:crl][:lists]).to eq(1)
263
- expect(SSLTest.cache_size[:crl][:bytes]).to be > 500
264
- end
265
- end
266
-
267
- describe '.follow_crl_redirects' do
268
- before { SSLTest.flush_cache }
244
+ describe '.follow_crl_redirects', retry: 5 do # fetches a live CRL
245
+ before { SSLTest.cache.clear }
269
246
  # 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
270
247
  it "fetch CRL list and updates cache" do
271
248
  uri = URI("http://crl.certigna.fr/certigna.crl")
@@ -274,11 +251,11 @@ describe SSLTest do
274
251
  expect(error).to be_nil
275
252
 
276
253
  # Check cache status
277
- cache = SSLTest.instance_variable_get('@crl_response_cache')
278
- expect(cache.size).to equal 1
279
- expect(cache.keys).to match_array [uri]
280
- expect(cache[uri].keys).to match_array [:body, :expires, :etag, :last_mod]
281
- expect(cache[uri][:expires]).to be > (Time.now + 3590)
254
+ cache_key = "ssl-test/crl/#{uri}"
255
+ entry = SSLTest.cache.read(cache_key)
256
+ expect(entry).not_to be_nil
257
+ expect(entry.keys).to match_array [:body, :expires, :etag, :last_mod]
258
+ expect(entry[:expires]).to be > (Time.now + 3590)
282
259
 
283
260
  # Make sure we return value from cache
284
261
  body2, error2 = nil, nil
@@ -287,7 +264,7 @@ describe SSLTest do
287
264
  expect(body2).to be(body) # using cache
288
265
 
289
266
  # Make sure we return cached value in case of 304
290
- cache[uri][:expires] = Time.now # cache is now expired
267
+ SSLTest.cache.write(cache_key, entry.merge(expires: Time.now), expires_in: nil) # cache is now expired
291
268
  body2, error2 = nil, nil
292
269
  time = Benchmark.realtime { body2, error2 = SSLTest.send(:follow_crl_redirects, uri) }
293
270
  expect(time).to be > 0.001 # a request is made
@@ -295,7 +272,34 @@ describe SSLTest do
295
272
  end
296
273
  end
297
274
 
298
- describe '.test_cert' do
275
+ describe '.cache', retry: 5 do # some examples hit live CRL/OCSP endpoints
276
+ # Restore the default in-process store after tests that swap the backend so
277
+ # global state doesn't leak between examples.
278
+ after { SSLTest.cache = SSLTest::MemoryStore.new }
279
+
280
+ it "defaults to an in-process MemoryStore" do
281
+ SSLTest.instance_variable_set(:@cache, nil) # reset memoized default
282
+ expect(SSLTest.cache).to be_a SSLTest::MemoryStore
283
+ end
284
+
285
+ it "uses the configured backend for CRL and OCSP" do
286
+ store = SSLTest::MemoryStore.new
287
+ SSLTest.cache = store
288
+ expect(store).to receive(:write).at_least(:once).and_call_original
289
+ expect(store).to receive(:read).at_least(:once).and_call_original
290
+ SSLTest.test("https://github.com")
291
+ end
292
+
293
+ it "cache_size (removed in 2.0) raises pointing to cache.size" do
294
+ expect { SSLTest.cache_size }.to raise_error(NoMethodError, /SSLTest\.cache\.size/)
295
+ end
296
+
297
+ it "flush_cache (removed in 2.0) raises pointing to cache.clear" do
298
+ expect { SSLTest.flush_cache }.to raise_error(NoMethodError, /SSLTest\.cache\.clear/)
299
+ end
300
+ end
301
+
302
+ describe '.test_cert', retry: 5 do # revocation checks hit live CRL/OCSP endpoints
299
303
  it "returns no error on valid SNI website" do
300
304
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_mycs_com_client.pem')))
301
305
  ca_bundle = OpenSSL::X509::Certificate.load(File.read(File.join(__dir__, 'fixtures/www_mycs_com_ca_bundle.pem')))
@@ -439,7 +443,7 @@ describe SSLTest do
439
443
  expect(valid).to eq(true)
440
444
  expect(cert).to eq(cert)
441
445
  # make sure both were used
442
- expect(SSLTest.cache_size).to match({
446
+ expect(SSLTest.cache.size).to match({
443
447
  crl: hash_including(lists: 1),
444
448
  ocsp: hash_including(responses: 1, errors: 0)
445
449
  })
@@ -489,4 +493,51 @@ describe SSLTest do
489
493
  end
490
494
  end
491
495
  end
496
+
497
+ describe '.revocation_order' do # no network: dispatch logic is stubbed
498
+ after { SSLTest.revocation_order = %i[crl ocsp] } # reset to the default
499
+
500
+ let(:cert) { OpenSSL::X509::Certificate.new }
501
+ let(:issuer) { OpenSSL::X509::Certificate.new }
502
+ let(:chain) { [cert, issuer] }
503
+
504
+ it "defaults to CRL first" do
505
+ expect(SSLTest.revocation_order).to eq(%i[crl ocsp])
506
+ end
507
+
508
+ it "validates the value" do
509
+ expect { SSLTest.revocation_order = %i[crl] }.to raise_error(ArgumentError)
510
+ expect { SSLTest.revocation_order = %i[ocsp bogus] }.to raise_error(ArgumentError)
511
+ end
512
+
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)
516
+ result = SSLTest.send(:test_chain_revocation, chain)
517
+ expect(result).to eq([false, nil, nil])
518
+ end
519
+
520
+ it "checks OCSP first when configured, falling back to CRL on error" do
521
+ SSLTest.revocation_order = %i[ocsp crl]
522
+ expect(SSLTest).to receive(:test_ocsp_revocation).ordered.and_return([false, "OCSP boom", nil])
523
+ expect(SSLTest).to receive(:test_crl_revocation).ordered.and_return(:crl_ok)
524
+ result = SSLTest.send(:test_chain_revocation, chain)
525
+ expect(result).to eq([false, nil, nil])
526
+ end
527
+
528
+ it "does not try the second method when the first one passes" do
529
+ SSLTest.revocation_order = %i[ocsp crl]
530
+ expect(SSLTest).to receive(:test_ocsp_revocation).and_return(:ocsp_ok)
531
+ expect(SSLTest).not_to receive(:test_crl_revocation)
532
+ expect(SSLTest.send(:test_chain_revocation, chain)).to eq([false, nil, nil])
533
+ end
534
+
535
+ it "combines error messages in the configured order when all methods fail" do
536
+ SSLTest.revocation_order = %i[ocsp crl]
537
+ allow(SSLTest).to receive(:test_ocsp_revocation).and_return([false, "OCSP boom", nil])
538
+ allow(SSLTest).to receive(:test_crl_revocation).and_return([false, "CRL boom", nil])
539
+ _revoked, message, _date = SSLTest.send(:test_chain_revocation, chain)
540
+ expect(message).to eq("OCSP: OCSP boom, CRL: CRL boom")
541
+ end
542
+ end
492
543
  end
data/ssl-test.gemspec CHANGED
@@ -22,4 +22,10 @@ Gem::Specification.new do |spec|
22
22
  spec.add_development_dependency "rspec"
23
23
  spec.add_development_dependency "rspec-retry"
24
24
  spec.add_development_dependency "webrick"
25
+ # Used to verify SSLTest.cache works with the classic Rails/ActiveSupport
26
+ # cache stores (MemoryStore, FileStore, NullStore, MemCacheStore via dalli,
27
+ # RedisCacheStore via redis).
28
+ spec.add_development_dependency "activesupport"
29
+ spec.add_development_dependency "dalli"
30
+ spec.add_development_dependency "redis"
25
31
  end
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: 1.6.0
4
+ version: 2.0.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-16 00:00:00.000000000 Z
10
+ date: 2026-06-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -79,6 +79,48 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: activesupport
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: dalli
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: redis
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
82
124
  email:
83
125
  - jobs@adrienjarthon.com
84
126
  executables: []
@@ -94,8 +136,10 @@ files:
94
136
  - Rakefile
95
137
  - lib/ssl-test.rb
96
138
  - lib/ssl-test/crl.rb
139
+ - lib/ssl-test/memory_store.rb
97
140
  - lib/ssl-test/object_size.rb
98
141
  - lib/ssl-test/ocsp.rb
142
+ - spec/cache_backends_spec.rb
99
143
  - spec/fixtures/digicert_com_ca_bundle.pem
100
144
  - spec/fixtures/digicert_com_client.pem
101
145
  - spec/fixtures/expired_cert_ca_bundle.pem
@@ -116,6 +160,7 @@ files:
116
160
  - spec/fixtures/www_github_com_client.pem
117
161
  - spec/fixtures/www_mycs_com_ca_bundle.pem
118
162
  - spec/fixtures/www_mycs_com_client.pem
163
+ - spec/memory_store_spec.rb
119
164
  - spec/ssl-test_spec.rb
120
165
  - ssl-test.gemspec
121
166
  homepage: https://github.com/jarthod/ssl-test
@@ -140,6 +185,7 @@ rubygems_version: 3.6.2
140
185
  specification_version: 4
141
186
  summary: Test website SSL certificate validity
142
187
  test_files:
188
+ - spec/cache_backends_spec.rb
143
189
  - spec/fixtures/digicert_com_ca_bundle.pem
144
190
  - spec/fixtures/digicert_com_client.pem
145
191
  - spec/fixtures/expired_cert_ca_bundle.pem
@@ -160,4 +206,5 @@ test_files:
160
206
  - spec/fixtures/www_github_com_client.pem
161
207
  - spec/fixtures/www_mycs_com_ca_bundle.pem
162
208
  - spec/fixtures/www_mycs_com_client.pem
209
+ - spec/memory_store_spec.rb
163
210
  - spec/ssl-test_spec.rb