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 +4 -4
- data/.github/workflows/ruby.yml +8 -0
- data/README.md +41 -11
- data/lib/ssl-test/crl.rb +23 -12
- data/lib/ssl-test/memory_store.rb +81 -0
- data/lib/ssl-test/ocsp.rb +15 -10
- data/lib/ssl-test.rb +34 -23
- data/spec/cache_backends_spec.rb +103 -0
- data/spec/fixtures/digicert_com_ca_bundle.pem +25 -27
- data/spec/fixtures/digicert_com_client.pem +25 -25
- data/spec/fixtures/google_com_ca_bundle.pem +46 -77
- data/spec/fixtures/google_com_client.pem +46 -77
- data/spec/fixtures/revoked_badssl_ca_bundle.pem +19 -21
- data/spec/fixtures/revoked_badssl_client.pem +19 -19
- data/spec/fixtures/www_demarches-simplifiees_fr_ca_bundle.pem +126 -102
- data/spec/fixtures/www_demarches-simplifiees_fr_client.pem +54 -50
- data/spec/fixtures/www_github_com_ca_bundle.pem +53 -61
- data/spec/fixtures/www_github_com_client.pem +22 -25
- data/spec/memory_store_spec.rb +79 -0
- data/spec/ssl-test_spec.rb +95 -77
- data/ssl-test.gemspec +7 -0
- metadata +63 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 886440c38c24dd0ad30e2fff8552fe89aa924010e8f9218032d030eceb29a422
|
|
4
|
+
data.tar.gz: b854a7f800c8aa3364ed78ebf29b4ec731e3904b7c33b682a39383a5096742d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 895f1193c0924d93b0bc4c29dea5d9de8b38b047f25c83273d13146c3bffa0a201676e320d6f4a6f373f231f87fdf0fda7bbef5a8983e40d489c8179097ed8dc
|
|
7
|
+
data.tar.gz: c1d2d3cd0014d31e01456dd5f0d1e58a59e820bb77f8e15a2755a27421a107b63d6cc653f115a7e50f19b475f40b1961bbdccab3b92181f1a5f812f68a668604
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -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 [
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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&.
|
|
81
|
+
if etag = cache_entry&.[](:etag)
|
|
71
82
|
req["If-None-Match"] = etag
|
|
72
|
-
elsif last_mod = cache_entry&.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.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
|
-
|
|
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
|
|
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, "
|
|
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
|
-
|
|
2
|
+
MIIG7TCCBdWgAwIBAgIQD4I+q2GZA3ujBecwxBeStjANBgkqhkiG9w0BAQsFADBE
|
|
3
3
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMR4wHAYDVQQDExVE
|
|
4
|
-
|
|
4
|
+
aWdpQ2VydCBFViBSU0EgQ0EgRzIwHhcNMjYwNjA5MDAwMDAwWhcNMjYwNzI1MjM1
|
|
5
5
|
OTU5WjCBwTETMBEGCysGAQQBgjc8AgEDEwJVUzEVMBMGCysGAQQBgjc8AgECEwRV
|
|
6
6
|
dGFoMR0wGwYDVQQPDBRQcml2YXRlIE9yZ2FuaXphdGlvbjEVMBMGA1UEBRMMNTI5
|
|
7
7
|
OTUzNy0wMTQyMQswCQYDVQQGEwJVUzENMAsGA1UECBMEVXRhaDENMAsGA1UEBxME
|
|
8
8
|
TGVoaTEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xGTAXBgNVBAMTEHd3dy5kaWdp
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|