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 +4 -4
- data/.github/workflows/ruby.yml +13 -1
- data/README.md +41 -4
- data/lib/ssl-test/crl.rb +35 -15
- data/lib/ssl-test/memory_store.rb +81 -0
- data/lib/ssl-test/ocsp.rb +15 -10
- data/lib/ssl-test.rb +67 -28
- data/spec/cache_backends_spec.rb +103 -0
- data/spec/memory_store_spec.rb +80 -0
- data/spec/ssl-test_spec.rb +100 -49
- data/ssl-test.gemspec +6 -0
- metadata +49 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 073a878a42dbba9b30e7c69ec4b435cd8f2c607e59a6b894099af73ea60526f8
|
|
4
|
+
data.tar.gz: 709b2f097b7d8a61922f2cfcee0cb6651046c57854dea0b3dc17c8367b7b856a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 150c1d97a77c0a37c71a180d7cd643f3a4264280f65261e2cb830b184fed00cb60458bf4d27ce1eb52225db506398d2e1480bf69d5a7d6ffc4ee7e5ad288a5b1
|
|
7
|
+
data.tar.gz: '08f66249c1b7d951b71283589d957b6bfe4451934ff155af255e4e31a5f77d0aa27bcd1dcc9cee1dcb0b1ddc450618d97c9a445c5067990009b14ad616933128'
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = -"
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
return [false, "
|
|
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
|
data/spec/ssl-test_spec.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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)"
|
|
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.
|
|
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 '.
|
|
241
|
-
before { SSLTest.
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
expect(
|
|
280
|
-
expect(
|
|
281
|
-
expect(
|
|
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
|
|
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 '.
|
|
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.
|
|
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:
|
|
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-
|
|
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
|