ssl-test 1.5.0 → 2.0.0

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: 56fc310f88c6cf01540cb98cd8704cf9e4bbbf63f107398ad0c506d809f1661f
4
- data.tar.gz: a6540f367f4fc62a6cb171e90084fe5d30a7d4247b7dd3f5fe76bd6c78770f52
3
+ metadata.gz: 886440c38c24dd0ad30e2fff8552fe89aa924010e8f9218032d030eceb29a422
4
+ data.tar.gz: b854a7f800c8aa3364ed78ebf29b4ec731e3904b7c33b682a39383a5096742d0
5
5
  SHA512:
6
- metadata.gz: 827d397d81d91b110ee6e3012f7f0e0816390da20b37bafc563b584fa7d9106b938663f47720c553028fc49f057588ae1fea9e319e561d4d9f2a30f2702ce13d
7
- data.tar.gz: b4f315bf38d8b86c3411047467c0c01c997f533190c1c54be597c480a5a8ec2bcced348fe8a417fbfa1c77d8ed47e3aab19aeff72167e67f3c4d01e20e4fd223
6
+ metadata.gz: 895f1193c0924d93b0bc4c29dea5d9de8b38b047f25c83273d13146c3bffa0a201676e320d6f4a6f373f231f87fdf0fda7bbef5a8983e40d489c8179097ed8dc
7
+ data.tar.gz: c1d2d3cd0014d31e01456dd5f0d1e58a59e820bb77f8e15a2755a27421a107b63d6cc653f115a7e50f19b475f40b1961bbdccab3b92181f1a5f812f68a668604
@@ -3,6 +3,14 @@ on: [push]
3
3
  jobs:
4
4
  specs:
5
5
  runs-on: ubuntu-22.04
6
+ services:
7
+ redis:
8
+ image: redis
9
+ ports: ['6379:6379']
10
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
11
+ memcached:
12
+ image: memcached
13
+ ports: ['11211:11211']
6
14
  steps:
7
15
  - uses: actions/checkout@v2
8
16
  - name: Set up Ruby
data/README.md CHANGED
@@ -59,23 +59,23 @@ error # => nil
59
59
  cert # => #<OpenSSL::X509::Certificate...>
60
60
  ```
61
61
 
62
- Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint by default:
62
+ Revoked certificates are detected using [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) by default:
63
63
 
64
64
  ```ruby
65
65
  valid, error, cert = SSLTest.test_url "https://revoked.badssl.com"
66
66
  valid # => false
67
- error # => "SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)"
67
+ error # => "SSL certificate revoked: Key Compromise (revocation date: 2019-10-07 20:30:39 UTC)"
68
68
  cert # => #<OpenSSL::X509::Certificate...>
69
69
  ```
70
70
 
71
- If the OCSP endpoint is missing, invalid or unreachable the certificate revocation will be tested using [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list).
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
- If both OCSP and CRL tests are impossible, the certificate will still be considered valid but with an error message:
73
+ If both CRL and OCSP tests are impossible, the certificate will still be considered valid but with an error message:
74
74
 
75
75
  ```ruby
76
76
  valid, error, cert = SSLTest.test_url "https://sitewithnoOCSPorCRL.com"
77
77
  valid # => true
78
- error # => "Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: Missing crlDistributionPoints extension"
78
+ error # => "Revocation test couldn't be performed: CRL: Missing crlDistributionPoints extension, OCSP: Missing OCSP URI in authorityInfoAccess extension"
79
79
  cert # => #<OpenSSL::X509::Certificate...>
80
80
  ```
81
81
 
@@ -96,20 +96,46 @@ This check will pass for self-signed certificates if the certificate is signed b
96
96
 
97
97
  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
98
 
99
- 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 OCSP 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.
99
+ After that it fetches the [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list) to verify if the certificate has been revoked. If the CRL is not available it'll query the [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint instead. It does this for every certificates in the chain (except the root which is trusted by your Operating System). It is possible the first one will be validated with CRL and the intermediate with OCSP depending on what they offer.
100
100
 
101
101
  ### Caching
102
102
 
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.
103
+ OCSP and CRL responses are cached, which makes subsequent testing faster and more robust (avoids network error and throttling).
104
104
 
105
105
  About the caching duration:
106
106
  - OCSP responses are cached until their "next_update" indicated inside the repsonse
107
107
  - OCSP errors are cached for 5 minutes
108
108
  - CRL responses are cached for 1 hour
109
109
 
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.
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. 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.
111
111
 
112
- You can check the size of the cache with `SSLTest.cache_size`, which returns:
112
+ #### Cache backend
113
+
114
+ 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`):
115
+
116
+ ```ruby
117
+ SSLTest.cache = Rails.cache # shared + compressed (e.g. memcache via Dalli)
118
+ SSLTest.cache = SSLTest::MemoryStore.new # the default in-process store
119
+ SSLTest.cache = MyCustomStore.new # anything responding to read/write/delete
120
+ ```
121
+
122
+ 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.
123
+
124
+ 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:
125
+
126
+ ```ruby
127
+ require "active_support/cache"
128
+ SSLTest.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes, compress: true)
129
+ ```
130
+
131
+ (It auto-prunes when it exceeds `size`, unlike the built-in store. Note the introspection helpers below are specific to `SSLTest::MemoryStore`.)
132
+
133
+ > **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:
134
+ >
135
+ > - memcached server: start it with `-I 64m`
136
+ > - 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 }`
137
+
138
+ You can check the size of the **built-in** store with `SSLTest.cache.size`, which returns:
113
139
 
114
140
  ```ruby
115
141
  {
@@ -125,7 +151,9 @@ You can check the size of the cache with `SSLTest.cache_size`, which returns:
125
151
  }
126
152
  ```
127
153
 
128
- You can also flush the cache using `SSLTest.flush_cache` if you want (not recommended)
154
+ You can also flush it using `SSLTest.cache.clear` if you want (not recommended).
155
+
156
+ `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
157
 
130
158
  ### Logging
131
159
 
@@ -140,11 +168,11 @@ SSLTest will log various messages depending on the log level you specify, exampl
140
168
  ```
141
169
  INFO -- : SSLTest https://www.anonymisation.gov.pf started
142
170
  DEBUG -- : SSLTest + test_chain_revocation: www.anonymisation.gov.pf
171
+ DEBUG -- : SSLTest + CRL: [false, "Missing crlDistributionPoints extension", nil]
143
172
  DEBUG -- : SSLTest + OCSP: fetch URI http://servicesca.ocsp.certigna.fr
144
173
  DEBUG -- : SSLTest + OCSP: 200 OK (4661 bytes)
145
174
  DEBUG -- : SSLTest + OCSP: ocsp_ok
146
175
  DEBUG -- : SSLTest + test_chain_revocation: Certigna Services CA
147
- DEBUG -- : SSLTest + OCSP: [false, "Missing OCSP URI in authorityInfoAccess extension", nil]
148
176
  DEBUG -- : SSLTest + CRL: fetch URI http://crl.certigna.fr/certigna.crl
149
177
  DEBUG -- : SSLTest + CRL: 200 OK (1152 bytes)
150
178
  DEBUG -- : SSLTest + CRL: crl_ok
@@ -167,6 +195,8 @@ But also **revoked certs** like most browsers (not handled by `curl`)
167
195
 
168
196
  See also github releases: https://github.com/jarthod/ssl-test/releases
169
197
 
198
+ * 2.0.0 - 2026-06-16: 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
+ * 1.6.0 - 2026-06-16: Check revocation with CRL first and fall back to OCSP (was OCSP first) to reduce revocation detection delay
170
200
  * 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)
171
201
  * 1.4.1 - 2022-10-24: Add support for "tcps://" scheme
172
202
  * 1.4.0 - 2021-01-16: Implemented CRL as fallback to OCSP + expose cache metrics + add logger support
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
@@ -13,9 +20,9 @@ module SSLTest
13
20
  # and building a hash with serial, time and reason takes even more.
14
21
  # So doing this would be MUCH faster in terms of CPU for subsequent tests on the same CRL
15
22
  # but would take a LOT of memory.
16
- # Also I expect most providers to support OCSP for first level cert (a lot of revokation),
17
- # which means we should have to use CRL mostly for intermediaries with much smaller CRL.
18
- # That's what Let's Encrypt is doing with their R3 intermediate for example.
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.
19
26
 
20
27
  private
21
28
 
@@ -54,10 +61,14 @@ module SSLTest
54
61
  def follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5, proxy_host: nil, proxy_port: nil)
55
62
  return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
56
63
 
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
64
+ # Return file from cache if not expired.
65
+ # CRL entries are kept in the backend for CRL_CACHE_RETENTION (much longer
66
+ # than CRL_CACHE_DURATION) so the cached body + caching headers survive past
67
+ # the freshness window and can be revalidated cheaply with a conditional
68
+ # request (304). We track our own freshness window with the :expires field.
69
+ cache_key = "#{CACHE_NAMESPACE}/crl/#{uri}"
70
+ cache_entry = cache.read(cache_key)
71
+ return [cache_entry[:body], nil] if cache_entry && cache_entry[:expires] > Time.now
61
72
 
62
73
  @logger&.debug { "SSLTest + CRL: fetch URI #{uri}" }
63
74
  path = uri.path == "" ? "/" : uri.path
@@ -67,9 +78,9 @@ module SSLTest
67
78
 
68
79
  req = Net::HTTP::Get.new(path)
69
80
  # Include conditional caching headers from cache to save bandwidth if list didn't change (304)
70
- if etag = cache_entry&.fetch(:etag)
81
+ if etag = cache_entry&.[](:etag)
71
82
  req["If-None-Match"] = etag
72
- elsif last_mod = cache_entry&.fetch(:last_mod)
83
+ elsif last_mod = cache_entry&.[](:last_mod)
73
84
  req["If-Modified-Since"] = last_mod
74
85
  end
75
86
  http_response = http.request(req)
@@ -77,19 +88,19 @@ module SSLTest
77
88
  when Net::HTTPNotModified
78
89
  # No changes, bump cache expiration time and return cached body
79
90
  @logger&.debug { "SSLTest + CRL: 304 Not Modified" }
80
- @crl_response_cache[uri][:expires] = Time.now + CRL_CACHE_DURATION
91
+ cache.write(cache_key, cache_entry.merge(expires: Time.now + CRL_CACHE_DURATION), expires_in: CRL_CACHE_RETENTION)
81
92
  [cache_entry[:body], nil]
82
93
  when Net::HTTPSuccess
83
94
  # Success, update (or add to) cache and return frech body
84
95
  @logger&.debug { "SSLTest + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
85
96
  @logger&.warn { "SSLTest + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
86
97
  @logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
87
- @crl_response_cache[uri] = {
98
+ cache.write(cache_key, {
88
99
  body: http_response.body,
89
100
  expires: Time.now + CRL_CACHE_DURATION,
90
101
  etag: http_response["Etag"],
91
102
  last_mod: http_response["Last-Modified"]
92
- }
103
+ }, expires_in: CRL_CACHE_RETENTION)
93
104
  [http_response.body, nil]
94
105
  when Net::HTTPRedirection
95
106
  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.5.0"
14
+ VERSION = -"2.0.0"
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,24 +84,30 @@ 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
@@ -136,20 +147,20 @@ module SSLTest
136
147
  chain[0..-2].each_with_index do |cert, i|
137
148
  @logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
138
149
 
139
- # Try with OCSP first
140
- ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
141
- @logger&.debug { "SSLTest + OCSP: #{ocsp_result}" }
142
- next if ocsp_result == :ocsp_ok # passed, go to next cert
143
- return ocsp_result if ocsp_result[0] == true # revoked
144
-
145
- # Otherwise it means there was an error so let's try with CRL instead
150
+ # Try with CRL first
146
151
  crl_result = test_crl_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
147
152
  @logger&.debug { "SSLTest + CRL: #{crl_result}" }
148
153
  next if crl_result == :crl_ok # passed, go to next cert
149
154
  return crl_result if crl_result[0] == true # revoked
150
155
 
156
+ # Otherwise it means there was an error so let's try with OCSP instead
157
+ ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
158
+ @logger&.debug { "SSLTest + OCSP: #{ocsp_result}" }
159
+ next if ocsp_result == :ocsp_ok # passed, go to next cert
160
+ return ocsp_result if ocsp_result[0] == true # revoked
161
+
151
162
  # If both method failed, return a soft fail with a combination of both error messages
152
- return [false, "OCSP: #{ocsp_result[1]}, CRL: #{crl_result[1]}", nil]
163
+ return [false, "CRL: #{crl_result[1]}, OCSP: #{ocsp_result[1]}", nil]
153
164
  end
154
165
 
155
166
  # If all test passed, the certificate is not revoked
@@ -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
@@ -1,19 +1,19 @@
1
1
  -----BEGIN CERTIFICATE-----
2
- MIIG7jCCBdagAwIBAgIQBz2KfzHX7LJ6+D64tWXIFTANBgkqhkiG9w0BAQsFADBE
2
+ MIIG7TCCBdWgAwIBAgIQD4I+q2GZA3ujBecwxBeStjANBgkqhkiG9w0BAQsFADBE
3
3
  MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMR4wHAYDVQQDExVE
4
- aWdpQ2VydCBFViBSU0EgQ0EgRzIwHhcNMjUxMTAzMDAwMDAwWhcNMjUxMjE5MjM1
4
+ aWdpQ2VydCBFViBSU0EgQ0EgRzIwHhcNMjYwNjA5MDAwMDAwWhcNMjYwNzI1MjM1
5
5
  OTU5WjCBwTETMBEGCysGAQQBgjc8AgEDEwJVUzEVMBMGCysGAQQBgjc8AgECEwRV
6
6
  dGFoMR0wGwYDVQQPDBRQcml2YXRlIE9yZ2FuaXphdGlvbjEVMBMGA1UEBRMMNTI5
7
7
  OTUzNy0wMTQyMQswCQYDVQQGEwJVUzENMAsGA1UECBMEVXRhaDENMAsGA1UEBxME
8
8
  TGVoaTEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xGTAXBgNVBAMTEHd3dy5kaWdp
9
- Y2VydC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCoFf4dPzKL
10
- K3KtscZ8hWhinM8VizoyYg6qF7zpGIphVHZtiuMowkKcHN8zk+Te9o3BFDnQYU6L
11
- LSvI3JqcCxe8G7xOLxHG94ZDwjhhXUo/CxcHgNgyuSx1+OC5bXISTibb7jF2YU8u
12
- 9rh4Vuyn13JNqU4HqrdwqEVZiW7rlC5yPO5sKadgjIu7CjjLsSYfen7uSfM0Mc4i
13
- qs8TNqD2jQViefdIvmQGVqyJf+Fk12LlW2TdUeh89aEOagK+ZhSJx2bipeyj0eBB
14
- ybJ8Q6kd4XLIPpx1QOV7yy755vLdfedllgnCv9C0BdHQF90SS+oADvyefXNNOTqT
15
- fe+XwDdfkATTAgMBAAGjggNcMIIDWDAfBgNVHSMEGDAWgBRqTlC/mGidW3sgddRZ
16
- AXlIZpIyBjAdBgNVHQ4EFgQUGd8hbnJtfOowjka/Sg7kOYi2oeEwKQYDVR0RBCIw
9
+ Y2VydC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDso4J0VSPj
10
+ 9L8seWfRT+SDm0gw4xANEk34z3gDbVzCUNvAmi5SQIDxoSpUJaVaprR4wpkXT/di
11
+ WJK7uV2iOozOFZ05cHGBmrv/SQAjZjLhKYHgRXQr/Kuu89IH++nxp9WdT12BEzsS
12
+ WDxVRCissVv8LZBNkH3rJDGSqaW8mLTvfF9DKL3ReXsit5/ibELWnYoOwbG1uPvi
13
+ 36Ennp7rR+hckf1bXN638K1/cBQQPAKiv750qxKDwv13XKwV9f3B/3RpRqIUcOAw
14
+ +cuj4nVtWE3+P/pkOEsy7ckH5aV0AIz5bF9prDVSmlYZm69o2Q03cUgt/BqcYkg7
15
+ YqOg203JLBNNAgMBAAGjggNbMIIDVzAfBgNVHSMEGDAWgBRqTlC/mGidW3sgddRZ
16
+ AXlIZpIyBjAdBgNVHQ4EFgQUMxYTQjzRD/R9svhyUBNlsQxeeTowKQYDVR0RBCIw
17
17
  IIIQd3d3LmRpZ2ljZXJ0LmNvbYIMZGlnaWNlcnQuY29tMEoGA1UdIARDMEEwCwYJ
18
18
  YIZIAYb9bAIBMDIGBWeBDAEBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGln
19
19
  aWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH
@@ -22,23 +22,22 @@ Z2lDZXJ0RVZSU0FDQUcyLmNybDA0oDKgMIYuaHR0cDovL2NybDQuZGlnaWNlcnQu
22
22
  Y29tL0RpZ2lDZXJ0RVZSU0FDQUcyLmNybDBzBggrBgEFBQcBAQRnMGUwJAYIKwYB
23
23
  BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTA9BggrBgEFBQcwAoYxaHR0
24
24
  cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0RVZSU0FDQUcyLmNydDAM
25
- BgNVHRMBAf8EAjAAMIIBfgYKKwYBBAHWeQIEAgSCAW4EggFqAWgAdgDd3Mo0ldfh
26
- FgXnlTL6x5/4PRxQ39sAOhQSdgosrLvIKgAAAZpIkZnpAAAEAwBHMEUCIC8jE0Ey
27
- 0Q7xGE3PenZUJKcj+18nm0myb27cIyHx2jsVAiEA+YQXhHCbJc17AXmWgc63Z7RT
28
- PvhE/Fq8k53T2H6SirYAdwDtPEvW6AbCpKIAV9vLJOI4Ad9RL+3EhsVwDyDdtz4/
29
- 4AAAAZpIkZnnAAAEAwBIMEYCIQD+lFI/hO60oJOUndldghaqClo9/dy0O6FWaP06
30
- Mn619QIhAMUSTSyO09wXaoiCUGrEZLlPvU1a3woB7Ja63sh1aUkbAHUApELFBklg
31
- YVSPD9TqnPt6LSZFTYepfy/fRVn2J086hFQAAAGaSJGZ+AAABAMARjBEAiBWGFi2
32
- 0F9ZZMzWcCcdmVpEz5y5T7cQ91z1DojVjc8Y4AIgGVU0KD/MTHi8b0nZb6B4uiD8
33
- k97tErH3VPd1N5CiMPcwDQYJKoZIhvcNAQELBQADggEBADwGDULnh6YXMyl5Zylo
34
- su7Bzw6lLG6RVYUtkJuiWeDfCCxaXkzUYIA/bsAQFBrWTQmyxBm6vIh/eNgGYUtO
35
- 05uFrRbijre0+DiF1QTtfg9lBPtXVp4GwpB3om7C283TQvlDczpyPKkVtYrvsp0L
36
- VUyt7LDPgaR69+ieVMQIn4pH/vWNGA8xlrL1jqv3W9RnGc1w1X/ceCshQD+VcNDe
37
- 7xm0tesmOzuFwJbEetuDLWfXHUs2UWkbGyS8XMt76mo6rsesex0GjO2VsTkrV5Fg
38
- xdboAzxOYE7ASn/LTbdtGQBuKyZHS1wH6g2q8vr9INk2rj+XvTCPe6DSffRrKdMf
39
- LbM=
25
+ BgNVHRMBAf8EAjAAMIIBfQYKKwYBBAHWeQIEAgSCAW0EggFpAWcAdgDCMX5XRRmj
26
+ Re5/ON6ykEHrx8IhWiK/f9W1rXaa2Q5SzQAAAZ6rBBTHAAAEAwBHMEUCIQClS0CE
27
+ o9NLQgyRRj+NXa6M5vHiMAeQQXdvxrztgEUfpAIgVPxQCT16MQ2CJVoDwW1hP0//
28
+ QEb7cE3fYpJazoOv/+oAdgDXbX0Q0af1d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVp
29
+ dwAAAZ6rBBSRAAAEAwBHMEUCIQC7NRXmFL0D3t/iLvfezwsB/DyzDuXle3u4BA8L
30
+ CT5LigIgZ5Tmcmgzv42s15QbHNEkgpi1DyocInQgxjo3yyVye94AdQCUTkOH+uzB
31
+ 74HzGSQmqBhlAcfTXzgCAT9yZ31VNy4Z2AAAAZ6rBBS1AAAEAwBGMEQCICLSuVkk
32
+ OVVXxrPAzuUj7zs5dpgDAVoVgzQelsixO8H6AiB0bB4SNowTnVZDEJ5knILVRQof
33
+ 4OrJVterjy9djCUUMDANBgkqhkiG9w0BAQsFAAOCAQEAMQ+sL8XkSJozMEFlXm3D
34
+ L5gN/ApjW+Yzz1naeWLuoz5qTO6q2mzB6b5F9PyWJH170xRFcY9DrAqY5KfXq2Pu
35
+ 2ASgUecTeRWTF4HMgelFelPhlqycpHHCBrxLJkI7X9XNG/ZFVT4VdP8LRofpPM8b
36
+ 0eHmt4RkiTKoSpbZbn06nobyb3UD7Snrya8iwMXmdHr5l9rknrmB6eYWbToRB+MN
37
+ PZXeabHjHp+etL8FUMc9HeFwWuI3rB0WstcrSiFtXI2gkdmR3wkMh1lmzTH8XHAx
38
+ 61mAo5VRwqC8zWZ0S1RJOFu7H829vDFetORJUbhIKPaYFEtABdOwkWwexeyc1TH0
39
+ nA==
40
40
  -----END CERTIFICATE-----
41
-
42
41
  -----BEGIN CERTIFICATE-----
43
42
  MIIFPDCCBCSgAwIBAgIQAWePH++IIlXYsKcOa3uyIDANBgkqhkiG9w0BAQsFADBh
44
43
  MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
@@ -69,7 +68,6 @@ WLBNN29Z/nbCS7H/qLGt7gViEvTIdU8x+H4l/XigZMUDaVmJ+B5d7cwSK7yOoQdf
69
68
  oIBGmA5Mp4LhMzo52rf//kXPfE3wYIZVHqVuxxlnTkFYmffCX9/Lon7SWaGdg6Rc
70
69
  k4RHhHLWtmz2lTZ5CEo2ljDsGzCFGJP7oT4q6Q8oFC38irvdKIJ95cUxYzj4tnOI
71
70
  -----END CERTIFICATE-----
72
-
73
71
  -----BEGIN CERTIFICATE-----
74
72
  MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
75
73
  MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3