ssl-test 1.3.1 → 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +68 -11
- data/Rakefile +3 -5
- data/lib/ssl-test.rb +93 -178
- data/lib/ssl-test/crl.rb +102 -0
- data/lib/ssl-test/object_size.rb +25 -0
- data/lib/ssl-test/ocsp.rb +142 -0
- data/spec/ssl-test_spec.rb +226 -0
- data/ssl-test.gemspec +2 -2
- metadata +9 -6
- data/test/ssl-test_test.rb +0 -112
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3192c4c66dc0345089108a47311eacba0e6b22ee1794896a9006bc2ff0fc7fce
|
4
|
+
data.tar.gz: 4306f6cc249d078ab07700ae5e42eac40d8387c6daefb41a1e43a43e169e6f29
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3ba176fda3fda4cf82f89c24a335fdb2e0ccdb7a735a228b2aae452afe7ebbc5b56cb6232d03a306315d9f14174a5f3ad8a383480b7180cee2ff7815e8471dfe
|
7
|
+
data.tar.gz: 7e7cf7ad82a36a541de9b65ad3bc30d4bf65577538249e6bf3980494d1829511c026b711571571c7b7ada5d60b70b4101776e3574734bb9fb4497db4e2fdb199
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
# SSLTest [![Build Status](https://travis-ci.
|
1
|
+
# SSLTest [![Build Status](https://travis-ci.com/jarthod/ssl-test.svg?branch=master)](https://travis-ci.com/jarthod/ssl-test)
|
2
2
|
|
3
|
-
A small ruby gem to help you test a website's SSL certificate.
|
3
|
+
A small ruby gem (with no dependencies) to help you test a website's SSL certificate.
|
4
4
|
|
5
5
|
```ruby
|
6
6
|
gem 'ssl-test'
|
@@ -47,7 +47,7 @@ cert # => nil
|
|
47
47
|
```
|
48
48
|
Default timeout values are 5 seconds each (open and read)
|
49
49
|
|
50
|
-
Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint:
|
50
|
+
Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint by default:
|
51
51
|
```ruby
|
52
52
|
valid, error, cert = SSLTest.test "https://revoked.badssl.com"
|
53
53
|
valid # => false
|
@@ -55,19 +55,74 @@ error # => "SSL certificate revoked: The certificate was revoked for an unknown
|
|
55
55
|
cert # => #<OpenSSL::X509::Certificate...>
|
56
56
|
```
|
57
57
|
|
58
|
-
If the OCSP endpoint is invalid or unreachable the certificate
|
58
|
+
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).
|
59
|
+
|
60
|
+
If both OCSP and CRL tests are impossible, the certificate will still be considered valid but with an error message:
|
59
61
|
```ruby
|
60
|
-
valid, error, cert = SSLTest.test "https://
|
62
|
+
valid, error, cert = SSLTest.test "https://sitewithnoOCSPorCRL.com"
|
61
63
|
valid # => true
|
62
|
-
error # => "
|
64
|
+
error # => "Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: Missing crlDistributionPoints extension"
|
63
65
|
cert # => #<OpenSSL::X509::Certificate...>
|
64
66
|
```
|
65
67
|
|
66
68
|
## How it works
|
67
69
|
|
68
|
-
SSLTester
|
70
|
+
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.
|
71
|
+
|
72
|
+
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.
|
73
|
+
|
74
|
+
### Caching
|
75
|
+
|
76
|
+
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.
|
77
|
+
|
78
|
+
About the caching duration:
|
79
|
+
- OCSP responses are cached until their "next_update" indicated inside the repsonse
|
80
|
+
- OCSP errors are cached for 5 minutes
|
81
|
+
- CRL responses are cached for 1 hour
|
82
|
+
|
83
|
+
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.
|
84
|
+
|
85
|
+
You can check the size of the cache with `SSLTest.cache_size`, which returns:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
{
|
89
|
+
crl: {
|
90
|
+
lists: 5,
|
91
|
+
bytes: 5123456
|
92
|
+
},
|
93
|
+
ocsp: {
|
94
|
+
responses: 350,
|
95
|
+
errors: 2,
|
96
|
+
bytes: 45876
|
97
|
+
}
|
98
|
+
}
|
99
|
+
```
|
69
100
|
|
70
|
-
|
101
|
+
You can also flush the cache using `SSLTest.flush_cache` if you want (not recommended)
|
102
|
+
|
103
|
+
### Logging
|
104
|
+
|
105
|
+
You can enable logging by setting `SSLTest.logger`, for example:
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
SSLTest.logger = Rails.logger
|
109
|
+
```
|
110
|
+
|
111
|
+
SSLTest will log various messages depending on the log level you specify, example:
|
112
|
+
|
113
|
+
```
|
114
|
+
INFO -- : SSLTest https://www.anonymisation.gov.pf started
|
115
|
+
DEBUG -- : SSLTest + test_chain_revocation: www.anonymisation.gov.pf
|
116
|
+
DEBUG -- : SSLTest + OCSP: fetch URI http://servicesca.ocsp.certigna.fr
|
117
|
+
DEBUG -- : SSLTest + OCSP: 200 OK (4661 bytes)
|
118
|
+
DEBUG -- : SSLTest + OCSP: ocsp_ok
|
119
|
+
DEBUG -- : SSLTest + test_chain_revocation: Certigna Services CA
|
120
|
+
DEBUG -- : SSLTest + OCSP: [false, "Missing OCSP URI in authorityInfoAccess extension", nil]
|
121
|
+
DEBUG -- : SSLTest + CRL: fetch URI http://crl.certigna.fr/certigna.crl
|
122
|
+
DEBUG -- : SSLTest + CRL: 200 OK (1152 bytes)
|
123
|
+
DEBUG -- : SSLTest + CRL: crl_ok
|
124
|
+
INFO -- : SSLTest https://www.anonymisation.gov.pf finished: revoked=false
|
125
|
+
```
|
71
126
|
|
72
127
|
### What kind of errors will SSLTest detect
|
73
128
|
|
@@ -83,6 +138,7 @@ But also **revoked certs** like most browsers (not handled by `curl`)
|
|
83
138
|
|
84
139
|
## Changelog
|
85
140
|
|
141
|
+
* 1.4.0 - 2021-01-16: Implemented CRL as fallback to OCSP + expose cache metrics + add logger support
|
86
142
|
* 1.3.1 - 2020-04-25: Improved caching of failed OCSP responses (#5)
|
87
143
|
* 1.3.0 - 2020-04-25: Added revoked cert detection using OCSP (#3)
|
88
144
|
|
@@ -90,6 +146,7 @@ But also **revoked certs** like most browsers (not handled by `curl`)
|
|
90
146
|
|
91
147
|
1. Fork it ( https://github.com/[my-github-username]/ssl-test/fork )
|
92
148
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
93
|
-
3.
|
94
|
-
4.
|
95
|
-
5.
|
149
|
+
3. Make sure the tests are passing (`rspec`)
|
150
|
+
4. Commit your changes (`git commit -am 'Add some feature'`)
|
151
|
+
5. Push to the branch (`git push origin my-new-feature`)
|
152
|
+
6. Create a new Pull Request
|
data/Rakefile
CHANGED
@@ -1,13 +1,11 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
|
-
require "
|
2
|
+
require "rspec/core/rake_task"
|
3
3
|
|
4
|
-
|
5
|
-
t.pattern = "test/*_test.rb"
|
6
|
-
end
|
4
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
5
|
|
8
6
|
desc "Open an irb session preloaded with ssl-test"
|
9
7
|
task :console do
|
10
8
|
sh "irb -rubygems -I lib -r ssl_test.rb"
|
11
9
|
end
|
12
10
|
|
13
|
-
task default: :
|
11
|
+
task default: :spec
|
data/lib/ssl-test.rb
CHANGED
@@ -2,128 +2,113 @@ require "net/http"
|
|
2
2
|
require "net/https"
|
3
3
|
require "openssl"
|
4
4
|
require "uri"
|
5
|
+
require "ssl-test/object_size"
|
6
|
+
require "ssl-test/ocsp"
|
7
|
+
require "ssl-test/crl"
|
5
8
|
|
6
9
|
module SSLTest
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
def self.test url, open_timeout: 5, read_timeout: 5, redirection_limit: 5
|
11
|
-
uri = URI.parse(url)
|
12
|
-
return if uri.scheme != 'https'
|
13
|
-
cert = failed_cert_reason = chain = nil
|
14
|
-
|
15
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
16
|
-
http.open_timeout = open_timeout
|
17
|
-
http.read_timeout = read_timeout
|
18
|
-
http.use_ssl = true
|
19
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
20
|
-
http.verify_callback = -> (verify_ok, store_context) {
|
21
|
-
cert = store_context.current_cert
|
22
|
-
chain = store_context.chain
|
23
|
-
failed_cert_reason = [store_context.error, store_context.error_string] if store_context.error != 0
|
24
|
-
verify_ok
|
25
|
-
}
|
26
|
-
|
27
|
-
begin
|
28
|
-
http.start { }
|
29
|
-
failed, revoked, message, revocation_date = test_ocsp_revocation(chain, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
|
30
|
-
return [nil, "OCSP test failed: #{message}", cert] if failed
|
31
|
-
return [false, "SSL certificate revoked: #{message} (revocation date: #{revocation_date})", cert] if revoked
|
32
|
-
return [true, "OCSP test couldn't be performed: #{message}", cert] if message
|
33
|
-
return [true, nil, cert]
|
34
|
-
rescue OpenSSL::SSL::SSLError => e
|
35
|
-
error = e.message
|
36
|
-
error = "error code %d: %s" % failed_cert_reason if failed_cert_reason
|
37
|
-
if error =~ /certificate verify failed/
|
38
|
-
domains = cert_domains(cert)
|
39
|
-
if matching_domains(domains, uri.host).none?
|
40
|
-
error = "hostname \"#{uri.host}\" does not match the server certificate (#{domains.join(', ')})"
|
41
|
-
end
|
42
|
-
end
|
43
|
-
return [false, error, cert]
|
44
|
-
rescue => e
|
45
|
-
return [nil, "SSL certificate test failed: #{e.message}"]
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
# https://docs.ruby-lang.org/en/2.2.0/OpenSSL/OCSP.html
|
50
|
-
# https://stackoverflow.com/questions/16244084/how-to-programmatically-check-if-a-certificate-has-been-revoked#answer-16257470
|
51
|
-
# Returns an array with [ocsp_check_failed, certificate_revoked, error_reason, revocation_date]
|
52
|
-
def self.test_ocsp_revocation chain, open_timeout: 5, read_timeout: 5, redirection_limit: 5
|
53
|
-
@ocsp_response_cache ||= {}
|
54
|
-
@ocsp_request_error_cache ||= {}
|
55
|
-
chain[0..-2].each_with_index do |cert, i|
|
56
|
-
# https://tools.ietf.org/html/rfc5280#section-4.1.2.2
|
57
|
-
# The serial number [...] MUST be unique for each certificate issued by a given CA (i.e., the issuer name and serial number identify a unique certificate)
|
58
|
-
unicity_key = "#{cert.issuer}/#{cert.serial}"
|
59
|
-
|
60
|
-
current_request_error_cache = @ocsp_request_error_cache[unicity_key]
|
61
|
-
return current_request_error_cache[:error] if current_request_error_cache && Time.now <= current_request_error_cache[:cache_until]
|
10
|
+
extend OCSP
|
11
|
+
extend CRL
|
62
12
|
|
63
|
-
|
64
|
-
issuer = chain[i + 1]
|
13
|
+
VERSION = -"1.4.0"
|
65
14
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
request.add_nonce
|
72
|
-
|
73
|
-
authority_info_access = cert.extensions.find do |extension|
|
74
|
-
extension.oid == "authorityInfoAccess"
|
75
|
-
end
|
76
|
-
|
77
|
-
# https://tools.ietf.org/html/rfc3280#section-4.2.2.1
|
78
|
-
# The authority information access extension [...] may be included in end entity or CA certificates, and it MUST be non-critical.
|
79
|
-
return ocsp_soft_fail_return("Missing authorityInfoAccess extension") unless authority_info_access
|
15
|
+
class << self
|
16
|
+
def test url, open_timeout: 5, read_timeout: 5, redirection_limit: 5
|
17
|
+
uri = URI.parse(url)
|
18
|
+
return if uri.scheme != 'https'
|
19
|
+
cert = failed_cert_reason = chain = nil
|
80
20
|
|
81
|
-
|
82
|
-
|
83
|
-
|
21
|
+
@logger&.info { "SSLTest #{url} started" }
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
23
|
+
http.open_timeout = open_timeout
|
24
|
+
http.read_timeout = read_timeout
|
25
|
+
http.use_ssl = true
|
26
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
27
|
+
http.verify_callback = -> (verify_ok, store_context) {
|
28
|
+
cert = store_context.current_cert
|
29
|
+
chain = store_context.chain
|
30
|
+
failed_cert_reason = [store_context.error, store_context.error_string] if store_context.error != 0
|
31
|
+
verify_ok
|
32
|
+
}
|
33
|
+
|
34
|
+
begin
|
35
|
+
http.start { }
|
36
|
+
revoked, message, revocation_date = test_chain_revocation(chain, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
|
37
|
+
@logger&.info { "SSLTest #{url} finished: revoked=#{revoked} #{message}" }
|
38
|
+
return [false, "SSL certificate revoked: #{message} (revocation date: #{revocation_date})", cert] if revoked
|
39
|
+
return [true, "Revocation test couldn't be performed: #{message}", cert] if message
|
40
|
+
return [true, nil, cert]
|
41
|
+
rescue OpenSSL::SSL::SSLError => e
|
42
|
+
error = e.message
|
43
|
+
error = "error code %d: %s" % failed_cert_reason if failed_cert_reason
|
44
|
+
if error =~ /certificate verify failed/
|
45
|
+
domains = cert_domains(cert)
|
46
|
+
if matching_domains(domains, uri.host).none?
|
47
|
+
error = "hostname \"#{uri.host}\" does not match the server certificate (#{domains.join(', ')})"
|
48
|
+
end
|
84
49
|
end
|
50
|
+
@logger&.info { "SSLTest #{url} finished: #{error}" }
|
51
|
+
return [false, error, cert]
|
52
|
+
rescue => e
|
53
|
+
@logger&.error { "SSLTest #{url} failed: #{e.message}" }
|
54
|
+
return [nil, "SSL certificate test failed: #{e.message}", cert]
|
55
|
+
end
|
56
|
+
end
|
85
57
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
# Check the response signature
|
100
|
-
store = OpenSSL::X509::Store.new
|
101
|
-
store.set_default_paths
|
102
|
-
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-verify
|
103
|
-
return ocsp_soft_fail_return("Signature verification failed (URI: #{ocsp_uri})", unicity_key) unless basic_response.verify(chain, store)
|
58
|
+
def cache_size
|
59
|
+
{
|
60
|
+
crl: {
|
61
|
+
lists: @crl_response_cache&.size || 0,
|
62
|
+
bytes: ObjectSize.size(@crl_response_cache)
|
63
|
+
},
|
64
|
+
ocsp: {
|
65
|
+
responses: @ocsp_response_cache&.size || 0,
|
66
|
+
errors: @ocsp_request_error_cache&.size || 0,
|
67
|
+
bytes: ObjectSize.size(@ocsp_response_cache) + ObjectSize.size(@ocsp_request_error_cache)
|
68
|
+
}
|
69
|
+
}
|
70
|
+
end
|
104
71
|
|
105
|
-
|
106
|
-
|
72
|
+
def flush_cache
|
73
|
+
@crl_response_cache = {}
|
74
|
+
@ocsp_response_cache = {}
|
75
|
+
@ocsp_request_error_cache = {}
|
76
|
+
end
|
107
77
|
|
108
|
-
|
109
|
-
|
78
|
+
def logger= logger
|
79
|
+
@logger = logger
|
80
|
+
end
|
110
81
|
|
111
|
-
|
82
|
+
private
|
112
83
|
|
113
|
-
|
84
|
+
# https://docs.ruby-lang.org/en/2.2.0/OpenSSL/OCSP.html
|
85
|
+
# https://stackoverflow.com/questions/16244084/how-to-programmatically-check-if-a-certificate-has-been-revoked#answer-16257470
|
86
|
+
# Returns an array with [certificate_revoked?, error_reason, revocation_date]
|
87
|
+
def test_chain_revocation chain, **options
|
88
|
+
# Test each certificates in the chain except the last one (root cert),
|
89
|
+
# which can only be revoked by removing it from the OS.
|
90
|
+
chain[0..-2].each_with_index do |cert, i|
|
91
|
+
@logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }
|
92
|
+
|
93
|
+
# Try with OCSP first
|
94
|
+
ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
|
95
|
+
@logger&.debug { "SSLTest + OCSP: #{ocsp_result}" }
|
96
|
+
next if ocsp_result == :ocsp_ok # passed, go to next cert
|
97
|
+
return ocsp_result if ocsp_result[0] == true # revoked
|
98
|
+
|
99
|
+
# Otherwise it means there was an error so let's try with CRL instead
|
100
|
+
crl_result = test_crl_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
|
101
|
+
@logger&.debug { "SSLTest + CRL: #{crl_result}" }
|
102
|
+
next if crl_result == :crl_ok # passed, go to next cert
|
103
|
+
return crl_result if crl_result[0] == true # revoked
|
104
|
+
|
105
|
+
# If both method failed, return a soft fail with a combination of both error messages
|
106
|
+
return [false, "OCSP: #{ocsp_result[1]}, CRL: #{crl_result[1]}", nil]
|
114
107
|
end
|
115
108
|
|
116
|
-
|
117
|
-
|
118
|
-
return [false, true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
|
109
|
+
# If all test passed, the certificate is not revoked
|
110
|
+
[false, nil, nil]
|
119
111
|
end
|
120
|
-
[false, false, nil, nil]
|
121
|
-
rescue => e
|
122
|
-
return [true, nil, e.message, nil]
|
123
|
-
end
|
124
|
-
|
125
|
-
class << self
|
126
|
-
private
|
127
112
|
|
128
113
|
def cert_field_to_hash field
|
129
114
|
field.to_a.each.with_object({}) do |v, h|
|
@@ -144,75 +129,5 @@ module SSLTest
|
|
144
129
|
domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
|
145
130
|
.select {|domain| domain.match?(hostname) }
|
146
131
|
end
|
147
|
-
|
148
|
-
# Returns an array with [response, error_message]
|
149
|
-
def follow_ocsp_redirects(uri, data, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
|
150
|
-
return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
|
151
|
-
|
152
|
-
path = uri.path == "" ? "/" : uri.path
|
153
|
-
http = Net::HTTP.new(uri.hostname, uri.port)
|
154
|
-
http.open_timeout = open_timeout
|
155
|
-
http.read_timeout = read_timeout
|
156
|
-
|
157
|
-
http_response = http.post(path, data, "content-type" => "application/ocsp-request")
|
158
|
-
case http_response
|
159
|
-
when Net::HTTPSuccess
|
160
|
-
[http_response, nil]
|
161
|
-
when Net::HTTPRedirection
|
162
|
-
follow_ocsp_redirects(URI(http_response["location"]), data, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit - 1)
|
163
|
-
else
|
164
|
-
[nil, "Wrong response type (#{http_response.class})"]
|
165
|
-
end
|
166
|
-
end
|
167
|
-
|
168
|
-
# https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
|
169
|
-
def ocsp_response_status_to_string(response_status)
|
170
|
-
case response_status
|
171
|
-
when OpenSSL::OCSP::RESPONSE_STATUS_INTERNALERROR
|
172
|
-
"Internal error in issuer"
|
173
|
-
when OpenSSL::OCSP::RESPONSE_STATUS_MALFORMEDREQUEST
|
174
|
-
"Illegal confirmation request"
|
175
|
-
when OpenSSL::OCSP::RESPONSE_STATUS_SIGREQUIRED
|
176
|
-
"You must sign the request and resubmit"
|
177
|
-
when OpenSSL::OCSP::RESPONSE_STATUS_TRYLATER
|
178
|
-
"Try again later"
|
179
|
-
when OpenSSL::OCSP::RESPONSE_STATUS_UNAUTHORIZED
|
180
|
-
"Your request is unauthorized"
|
181
|
-
else
|
182
|
-
"Unknown reason"
|
183
|
-
end
|
184
|
-
end
|
185
|
-
|
186
|
-
def ocsp_soft_fail_return(reason, unicity_key = nil)
|
187
|
-
error = [false, false, reason, nil]
|
188
|
-
@ocsp_request_error_cache[unicity_key] = { error: error, cache_until: Time.now + OCSP_REQUEST_ERROR_CACHE_DURATION } if unicity_key
|
189
|
-
error
|
190
|
-
end
|
191
|
-
|
192
|
-
def revocation_reason_to_string(revocation_reason)
|
193
|
-
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
|
194
|
-
case revocation_reason
|
195
|
-
when OpenSSL::OCSP::REVOKED_STATUS_AFFILIATIONCHANGED
|
196
|
-
"The certificate subject's name or other information changed"
|
197
|
-
when OpenSSL::OCSP::REVOKED_STATUS_CACOMPROMISE
|
198
|
-
"This CA certificate was revoked due to a key compromise"
|
199
|
-
when OpenSSL::OCSP::REVOKED_STATUS_CERTIFICATEHOLD
|
200
|
-
"The certificate is on hold"
|
201
|
-
when OpenSSL::OCSP::REVOKED_STATUS_CESSATIONOFOPERATION
|
202
|
-
"The certificate is no longer needed"
|
203
|
-
when OpenSSL::OCSP::REVOKED_STATUS_KEYCOMPROMISE
|
204
|
-
"The certificate was revoked due to a key compromise"
|
205
|
-
when OpenSSL::OCSP::REVOKED_STATUS_NOSTATUS
|
206
|
-
"The certificate was revoked for an unknown reason"
|
207
|
-
when OpenSSL::OCSP::REVOKED_STATUS_REMOVEFROMCRL
|
208
|
-
"The certificate was previously on hold and should now be removed from the CRL"
|
209
|
-
when OpenSSL::OCSP::REVOKED_STATUS_SUPERSEDED
|
210
|
-
"The certificate was superseded by a new certificate"
|
211
|
-
when OpenSSL::OCSP::REVOKED_STATUS_UNSPECIFIED
|
212
|
-
"The certificate was revoked for an unspecified reason"
|
213
|
-
else
|
214
|
-
"Unknown reason"
|
215
|
-
end
|
216
|
-
end
|
217
132
|
end
|
218
133
|
end
|
data/lib/ssl-test/crl.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
module SSLTest
|
2
|
+
module CRL
|
3
|
+
CRL_CACHE_DURATION = 3600 # 1 hour
|
4
|
+
|
5
|
+
# A note about caching:
|
6
|
+
# I choose to only cache the raw HTTP body here (and not the parsed list or better a hash
|
7
|
+
# indexed by certificat serial). This is not CPU efficient because it means every time we
|
8
|
+
# need to check a cert from a cached CRL we need to parse it again, instantiate the list
|
9
|
+
# of Revoked certs and then iterate to find it (there's no API to find one cert without
|
10
|
+
# generting the list unfortuantely).
|
11
|
+
# I did this because of memory efficiency, because for big 20MB CRL list (so taking 20MB
|
12
|
+
# in memory cache), the parsed version takes more than 100M, the list of Revoked certs 120MB,
|
13
|
+
# and building a hash with serial, time and reason takes even more.
|
14
|
+
# So doing this would be MUCH faster in terms of CPU for subsequent tests on the same CRL
|
15
|
+
# 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.
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def test_crl_revocation cert, issuer:, chain:, **options
|
23
|
+
crl_distribution_points = cert.extensions.find do |extension|
|
24
|
+
extension.oid == "crlDistributionPoints"
|
25
|
+
end
|
26
|
+
|
27
|
+
return [false, "Missing crlDistributionPoints extension", nil] unless crl_distribution_points
|
28
|
+
|
29
|
+
# OpenSSL 2.2+ may simplify this: https://github.com/ruby/openssl/commit/ea702a106d3d8136c48f244593de95666be0edf9
|
30
|
+
crl = crl_distribution_points.value.split("\n").find do |description|
|
31
|
+
description.match?(/URI:/)
|
32
|
+
end
|
33
|
+
|
34
|
+
return [false, "Missing CRL URI in crlDistributionPoints extension", nil] unless crl
|
35
|
+
|
36
|
+
crl_uri = URI(crl[/URI:(.*)/, 1])
|
37
|
+
http_response, crl_request_error = follow_crl_redirects(crl_uri, **options)
|
38
|
+
return [false, "Request failed (URI: #{crl_uri}): #{crl_request_error}", nil] unless http_response
|
39
|
+
|
40
|
+
response = OpenSSL::X509::CRL.new http_response
|
41
|
+
return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)
|
42
|
+
|
43
|
+
revoked = response.revoked.find { |r| r.serial == cert.serial }
|
44
|
+
if revoked
|
45
|
+
reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
|
46
|
+
return [true, reason || "Unknown reason", revoked.time]
|
47
|
+
else
|
48
|
+
end
|
49
|
+
|
50
|
+
:crl_ok
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns an array with [response, error_message]
|
54
|
+
def follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
|
55
|
+
return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
|
56
|
+
|
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
|
61
|
+
|
62
|
+
@logger&.debug { "SSLTest + CRL: fetch URI #{uri}" }
|
63
|
+
path = uri.path == "" ? "/" : uri.path
|
64
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
65
|
+
http.open_timeout = open_timeout
|
66
|
+
http.read_timeout = read_timeout
|
67
|
+
|
68
|
+
req = Net::HTTP::Get.new(path)
|
69
|
+
# Include conditional caching headers from cache to save bandwidth if list didn't change (304)
|
70
|
+
if etag = cache_entry&.fetch(:etag)
|
71
|
+
req["If-None-Match"] = etag
|
72
|
+
elsif last_mod = cache_entry&.fetch(:last_mod)
|
73
|
+
req["If-Modified-Since"] = last_mod
|
74
|
+
end
|
75
|
+
http_response = http.request(req)
|
76
|
+
case http_response
|
77
|
+
when Net::HTTPNotModified
|
78
|
+
# No changes, bump cache expiration time and return cached body
|
79
|
+
@logger&.debug { "SSLTest + CRL: 304 Not Modified" }
|
80
|
+
@crl_response_cache[uri][:expires] = Time.now + CRL_CACHE_DURATION
|
81
|
+
[cache_entry[:body], nil]
|
82
|
+
when Net::HTTPSuccess
|
83
|
+
# Success, update (or add to) cache and return frech body
|
84
|
+
@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
|
86
|
+
@logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
|
87
|
+
@crl_response_cache[uri] = {
|
88
|
+
body: http_response.body,
|
89
|
+
expires: Time.now + CRL_CACHE_DURATION,
|
90
|
+
etag: http_response["Etag"],
|
91
|
+
last_mod: http_response["Last-Modified"]
|
92
|
+
}
|
93
|
+
[http_response.body, nil]
|
94
|
+
when Net::HTTPRedirection
|
95
|
+
follow_crl_redirects(URI(http_response["location"]), open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit - 1)
|
96
|
+
else
|
97
|
+
@logger&.debug { "SSLTest + CRL: Error: #{http_response.class}" }
|
98
|
+
[nil, "Wrong response type (#{http_response.class})"]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'objspace'
|
2
|
+
|
3
|
+
module ObjectSize
|
4
|
+
def self.size(obj)
|
5
|
+
case obj
|
6
|
+
when String
|
7
|
+
obj.bytesize
|
8
|
+
when Integer
|
9
|
+
obj.size
|
10
|
+
when Hash
|
11
|
+
sum = 0
|
12
|
+
obj.each do |key, val|
|
13
|
+
sum += size(key)
|
14
|
+
sum += size(val)
|
15
|
+
end
|
16
|
+
sum
|
17
|
+
when Array
|
18
|
+
obj.reduce(0) do |sum, val|
|
19
|
+
sum + size(val)
|
20
|
+
end
|
21
|
+
else
|
22
|
+
ObjectSpace.memsize_of(obj)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
module SSLTest
|
2
|
+
module OCSP
|
3
|
+
ERROR_CACHE_DURATION = 5 * 60 # 5 minutes
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def test_ocsp_revocation cert, issuer:, chain:, **options
|
8
|
+
@ocsp_response_cache ||= {}
|
9
|
+
@ocsp_request_error_cache ||= {}
|
10
|
+
|
11
|
+
unicity_key = "#{cert.issuer}/#{cert.serial}"
|
12
|
+
|
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]
|
15
|
+
|
16
|
+
if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
|
17
|
+
authority_info_access = cert.extensions.find do |extension|
|
18
|
+
extension.oid == "authorityInfoAccess"
|
19
|
+
end
|
20
|
+
|
21
|
+
return ocsp_soft_fail_return("Missing authorityInfoAccess extension") unless authority_info_access
|
22
|
+
|
23
|
+
# OpenSSL 2.2+ may simplify this: https://github.com/ruby/openssl/commit/ea702a106d3d8136c48f244593de95666be0edf9
|
24
|
+
ocsp = authority_info_access.value.split("\n").find do |description|
|
25
|
+
description.start_with?("OCSP")
|
26
|
+
end
|
27
|
+
|
28
|
+
return ocsp_soft_fail_return("Missing OCSP URI in authorityInfoAccess extension") unless ocsp
|
29
|
+
|
30
|
+
digest = OpenSSL::Digest::SHA1.new
|
31
|
+
certificate_id = OpenSSL::OCSP::CertificateId.new(cert, issuer, digest)
|
32
|
+
|
33
|
+
request = OpenSSL::OCSP::Request.new
|
34
|
+
request.add_certid certificate_id
|
35
|
+
request.add_nonce
|
36
|
+
|
37
|
+
ocsp_uri = URI(ocsp[/URI:(.*)/, 1])
|
38
|
+
http_response, ocsp_request_error = follow_ocsp_redirects(ocsp_uri, request.to_der, **options)
|
39
|
+
return ocsp_soft_fail_return("Request failed (URI: #{ocsp_uri}): #{ocsp_request_error}", unicity_key) unless http_response
|
40
|
+
|
41
|
+
response = OpenSSL::OCSP::Response.new http_response
|
42
|
+
# https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
|
43
|
+
return ocsp_soft_fail_return("Unsuccessful response (URI: #{ocsp_uri}): #{ocsp_response_status_to_string(response.status)}", unicity_key) unless response.status == OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL
|
44
|
+
basic_response = response.basic
|
45
|
+
|
46
|
+
# Check the response signature
|
47
|
+
store = OpenSSL::X509::Store.new
|
48
|
+
store.set_default_paths
|
49
|
+
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-verify
|
50
|
+
return ocsp_soft_fail_return("Signature verification failed (URI: #{ocsp_uri})", unicity_key) unless basic_response.verify(chain, store)
|
51
|
+
|
52
|
+
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/Request.html#method-i-check_nonce
|
53
|
+
return ocsp_soft_fail_return("Nonce check failed (URI: #{ocsp_uri})", unicity_key) unless request.check_nonce(basic_response) != 0
|
54
|
+
|
55
|
+
# https://ruby-doc.org/stdlib-2.3.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-status
|
56
|
+
response_certificate_id, status, reason, revocation_time, _this_update, next_update, _extensions = basic_response.status.first
|
57
|
+
|
58
|
+
return ocsp_soft_fail_return("Serial check failed (URI: #{ocsp_uri})", unicity_key) unless response_certificate_id.serial == certificate_id.serial
|
59
|
+
|
60
|
+
@ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
|
61
|
+
end
|
62
|
+
|
63
|
+
ocsp_response = @ocsp_response_cache[unicity_key]
|
64
|
+
|
65
|
+
return [true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
|
66
|
+
:ocsp_ok
|
67
|
+
end
|
68
|
+
|
69
|
+
# Returns an array with [response, error_message]
|
70
|
+
def follow_ocsp_redirects(uri, data, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
|
71
|
+
return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
|
72
|
+
|
73
|
+
@logger&.debug { "SSLTest + OCSP: fetch URI #{uri}" }
|
74
|
+
path = uri.path == "" ? "/" : uri.path
|
75
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
76
|
+
http.open_timeout = open_timeout
|
77
|
+
http.read_timeout = read_timeout
|
78
|
+
|
79
|
+
http_response = http.post(path, data, "content-type" => "application/ocsp-request")
|
80
|
+
case http_response
|
81
|
+
when Net::HTTPSuccess
|
82
|
+
@logger&.debug { "SSLTest + OCSP: 200 OK (#{http_response.body.bytesize} bytes)" }
|
83
|
+
[http_response.body, nil]
|
84
|
+
when Net::HTTPRedirection
|
85
|
+
follow_ocsp_redirects(URI(http_response["location"]), data, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit - 1)
|
86
|
+
else
|
87
|
+
@logger&.debug { "SSLTest + OCSP: Error: #{http_response.class}" }
|
88
|
+
[nil, "Wrong response type (#{http_response.class})"]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
|
93
|
+
def ocsp_response_status_to_string(response_status)
|
94
|
+
case response_status
|
95
|
+
when OpenSSL::OCSP::RESPONSE_STATUS_INTERNALERROR
|
96
|
+
"Internal error in issuer"
|
97
|
+
when OpenSSL::OCSP::RESPONSE_STATUS_MALFORMEDREQUEST
|
98
|
+
"Illegal confirmation request"
|
99
|
+
when OpenSSL::OCSP::RESPONSE_STATUS_SIGREQUIRED
|
100
|
+
"You must sign the request and resubmit"
|
101
|
+
when OpenSSL::OCSP::RESPONSE_STATUS_TRYLATER
|
102
|
+
"Try again later"
|
103
|
+
when OpenSSL::OCSP::RESPONSE_STATUS_UNAUTHORIZED
|
104
|
+
"Your request is unauthorized"
|
105
|
+
else
|
106
|
+
"Unknown reason"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def revocation_reason_to_string(revocation_reason)
|
111
|
+
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
|
112
|
+
case revocation_reason
|
113
|
+
when OpenSSL::OCSP::REVOKED_STATUS_AFFILIATIONCHANGED
|
114
|
+
"The certificate subject's name or other information changed"
|
115
|
+
when OpenSSL::OCSP::REVOKED_STATUS_CACOMPROMISE
|
116
|
+
"This CA certificate was revoked due to a key compromise"
|
117
|
+
when OpenSSL::OCSP::REVOKED_STATUS_CERTIFICATEHOLD
|
118
|
+
"The certificate is on hold"
|
119
|
+
when OpenSSL::OCSP::REVOKED_STATUS_CESSATIONOFOPERATION
|
120
|
+
"The certificate is no longer needed"
|
121
|
+
when OpenSSL::OCSP::REVOKED_STATUS_KEYCOMPROMISE
|
122
|
+
"The certificate was revoked due to a key compromise"
|
123
|
+
when OpenSSL::OCSP::REVOKED_STATUS_NOSTATUS
|
124
|
+
"The certificate was revoked for an unknown reason"
|
125
|
+
when OpenSSL::OCSP::REVOKED_STATUS_REMOVEFROMCRL
|
126
|
+
"The certificate was previously on hold and should now be removed from the CRL"
|
127
|
+
when OpenSSL::OCSP::REVOKED_STATUS_SUPERSEDED
|
128
|
+
"The certificate was superseded by a new certificate"
|
129
|
+
when OpenSSL::OCSP::REVOKED_STATUS_UNSPECIFIED
|
130
|
+
"The certificate was revoked for an unspecified reason"
|
131
|
+
else
|
132
|
+
"Unknown reason"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def ocsp_soft_fail_return(reason, unicity_key = nil)
|
137
|
+
error = [false, reason, nil]
|
138
|
+
@ocsp_request_error_cache[unicity_key] = { error: error, expires: Time.now + ERROR_CACHE_DURATION } if unicity_key
|
139
|
+
error
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
require "ssl-test"
|
2
|
+
require "benchmark"
|
3
|
+
|
4
|
+
# Uncomment for debug logging:
|
5
|
+
# require "logger"
|
6
|
+
# SSLTest.logger = Logger.new(STDOUT)
|
7
|
+
|
8
|
+
describe SSLTest do
|
9
|
+
describe '.test' do
|
10
|
+
it "returns no error on valid SNI website" do
|
11
|
+
valid, error, cert = SSLTest.test("https://www.mycs.com")
|
12
|
+
expect(error).to be_nil
|
13
|
+
expect(valid).to eq(true)
|
14
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
15
|
+
end
|
16
|
+
|
17
|
+
it "returns no error on valid SAN" do
|
18
|
+
valid, error, cert = SSLTest.test("https://1000-sans.badssl.com/")
|
19
|
+
expect(error).to be_nil
|
20
|
+
expect(valid).to eq(true)
|
21
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
22
|
+
end
|
23
|
+
|
24
|
+
it "returns no error when no CN" do
|
25
|
+
skip "Expired for the moment https://github.com/chromium/badssl.com/issues/447"
|
26
|
+
valid, error, cert = SSLTest.test("https://no-common-name.badssl.com/")
|
27
|
+
expect(error).to be_nil
|
28
|
+
expect(valid).to eq(true)
|
29
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
30
|
+
end
|
31
|
+
|
32
|
+
it "works with websites blocking http requests" do
|
33
|
+
valid, error, cert = SSLTest.test("https://obyava.ua")
|
34
|
+
expect(error).to be_nil
|
35
|
+
expect(valid).to eq(true)
|
36
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
37
|
+
end
|
38
|
+
|
39
|
+
it "returns error on self signed certificate" do
|
40
|
+
valid, error, cert = SSLTest.test("https://self-signed.badssl.com/")
|
41
|
+
expect(error).to eq ("error code 18: self signed certificate")
|
42
|
+
expect(valid).to eq(false)
|
43
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
44
|
+
end
|
45
|
+
|
46
|
+
it "returns error on incomplete chain" do
|
47
|
+
valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
|
48
|
+
expect(error).to eq ("error code 20: unable to get local issuer certificate")
|
49
|
+
expect(valid).to eq(false)
|
50
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
51
|
+
end
|
52
|
+
|
53
|
+
it "returns error on untrusted root" do
|
54
|
+
valid, error, cert = SSLTest.test("https://untrusted-root.badssl.com/")
|
55
|
+
expect(error).to eq ("error code 19: self signed certificate in certificate chain")
|
56
|
+
expect(valid).to eq(false)
|
57
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
58
|
+
end
|
59
|
+
|
60
|
+
it "returns error on invalid host" do
|
61
|
+
valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
|
62
|
+
expect(error).to include('hostname "wrong.host.badssl.com" does not match the server certificate')
|
63
|
+
expect(valid).to eq(false)
|
64
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
65
|
+
end
|
66
|
+
|
67
|
+
it "returns error on expired cert" do
|
68
|
+
valid, error, cert = SSLTest.test("https://expired.badssl.com/")
|
69
|
+
expect(error).to eq ("error code 10: certificate has expired")
|
70
|
+
expect(valid).to eq(false)
|
71
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
72
|
+
end
|
73
|
+
|
74
|
+
it "returns undetermined state on unhandled error" do
|
75
|
+
valid, error, cert = SSLTest.test("https://pijoinlrfgind.com")
|
76
|
+
expect(error).to eq ("SSL certificate test failed: Failed to open TCP connection to pijoinlrfgind.com:443 (getaddrinfo: Name or service not known)")
|
77
|
+
expect(valid).to be_nil
|
78
|
+
expect(cert).to be_nil
|
79
|
+
end
|
80
|
+
|
81
|
+
it "stops on timeouts" do
|
82
|
+
valid, error, cert = SSLTest.test("https://updown.io", open_timeout: 0)
|
83
|
+
expect(error).to eq ("SSL certificate test failed: Net::OpenTimeout")
|
84
|
+
expect(valid).to be_nil
|
85
|
+
expect(cert).to be_nil
|
86
|
+
end
|
87
|
+
|
88
|
+
it "reports revocation exceptions" do
|
89
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).and_raise(ArgumentError.new("test"))
|
90
|
+
valid, error, cert = SSLTest.test("https://updown.io")
|
91
|
+
expect(error).to eq ("SSL certificate test failed: test")
|
92
|
+
expect(valid).to be_nil
|
93
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
94
|
+
end
|
95
|
+
|
96
|
+
it "returns error on revoked cert (OCSP)" do
|
97
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
98
|
+
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
99
|
+
valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
|
100
|
+
expect(error).to eq ("SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)")
|
101
|
+
expect(valid).to eq(false)
|
102
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
103
|
+
end
|
104
|
+
|
105
|
+
it "returns error on revoked cert (CRL)" do
|
106
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
|
107
|
+
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
108
|
+
valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
|
109
|
+
expect(error).to eq ("SSL certificate revoked: Unknown reason (revocation date: 2019-10-07 20:30:39 UTC)")
|
110
|
+
expect(valid).to eq(false)
|
111
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
112
|
+
end
|
113
|
+
|
114
|
+
it "stops following redirection after the limit for the revoked certs check" do
|
115
|
+
valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
|
116
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: Request failed (URI: http://ocsp.digicert.com): Too many redirections (> 0), CRL: Request failed (URI: http://crl3.digicert.com/sha2-ha-server-g6.crl): Too many redirections (> 0)")
|
117
|
+
expect(valid).to eq(true)
|
118
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
119
|
+
end
|
120
|
+
|
121
|
+
it "warns when the OCSP URI is missing" do
|
122
|
+
# Disable CRL fallback to see error message
|
123
|
+
expect(SSLTest).to receive(:test_crl_revocation).once.and_return([false, "skip CRL", nil])
|
124
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
125
|
+
valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
|
126
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: Missing OCSP URI in authorityInfoAccess extension, CRL: skip CRL")
|
127
|
+
expect(valid).to eq(true)
|
128
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
129
|
+
end
|
130
|
+
|
131
|
+
it "works with CRL only" do
|
132
|
+
# Disable OCSP
|
133
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).twice.and_return([false, "skip OCSP", nil])
|
134
|
+
expect(SSLTest).to receive(:follow_crl_redirects).twice.and_call_original
|
135
|
+
valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
|
136
|
+
expect(error).to be_nil
|
137
|
+
expect(valid).to eq(true)
|
138
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
139
|
+
end
|
140
|
+
|
141
|
+
it "warns when the CRL URI is missing" do
|
142
|
+
# Disable OCSP to see error message
|
143
|
+
expect(SSLTest).to receive(:test_ocsp_revocation).once.and_return([false, "skip OCSP", nil])
|
144
|
+
expect(SSLTest).not_to receive(:follow_crl_redirects)
|
145
|
+
valid, error, cert = SSLTest.test("https://meta.updown.io")
|
146
|
+
expect(error).to eq ("Revocation test couldn't be performed: OCSP: skip OCSP, CRL: Missing crlDistributionPoints extension")
|
147
|
+
expect(valid).to eq(true)
|
148
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
149
|
+
end
|
150
|
+
|
151
|
+
it "works with OCSP for first cert and CRL for intermediate (Let's Encrypt R3 intermediate)" do
|
152
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
153
|
+
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
154
|
+
valid, error, cert = SSLTest.test("https://meta.updown.io/")
|
155
|
+
expect(error).to be_nil
|
156
|
+
expect(valid).to eq(true)
|
157
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
158
|
+
end
|
159
|
+
|
160
|
+
it "works with OCSP for first cert and CRL for intermediate (Certigna Services CA)" do
|
161
|
+
expect(SSLTest).to receive(:follow_ocsp_redirects).once.and_call_original
|
162
|
+
expect(SSLTest).to receive(:follow_crl_redirects).once.and_call_original
|
163
|
+
# Similar chain: https://www.demarches-simplifiees.fr
|
164
|
+
valid, error, cert = SSLTest.test("https://www.anonymisation.gov.pf")
|
165
|
+
expect(error).to be_nil
|
166
|
+
expect(valid).to eq(true)
|
167
|
+
expect(cert).to be_a OpenSSL::X509::Certificate
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
describe '.cache_size' do
|
172
|
+
before { SSLTest.flush_cache }
|
173
|
+
|
174
|
+
it "returns 0 by default" do
|
175
|
+
expect(SSLTest.cache_size).to eq({
|
176
|
+
crl: { bytes: 0, lists: 0 },
|
177
|
+
ocsp: { bytes: 0, errors: 0, responses: 0 }
|
178
|
+
})
|
179
|
+
end
|
180
|
+
|
181
|
+
it "returns CRL cache size properly" do
|
182
|
+
SSLTest.send(:follow_crl_redirects, URI("http://crl.certigna.fr/certigna.crl")) # 1.3k
|
183
|
+
SSLTest.send(:follow_crl_redirects, URI("http://crl3.digicert.com/ssca-sha2-g6.crl")) # 19M
|
184
|
+
expect(SSLTest.cache_size[:crl][:lists]).to eq(2)
|
185
|
+
expect(SSLTest.cache_size[:crl][:bytes]).to be > 19_000_000
|
186
|
+
end
|
187
|
+
|
188
|
+
it "returns OCSP cache size properly" do
|
189
|
+
SSLTest.test("https://updown.io")
|
190
|
+
expect(SSLTest.cache_size[:ocsp][:responses]).to eq(2)
|
191
|
+
expect(SSLTest.cache_size[:ocsp][:errors]).to eq(0)
|
192
|
+
expect(SSLTest.cache_size[:ocsp][:bytes]).to be > 200
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
describe '.follow_crl_redirects' do
|
197
|
+
before { SSLTest.flush_cache }
|
198
|
+
# 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
|
199
|
+
it "fetch CRL list and updates cache" do
|
200
|
+
uri = URI("http://crl.certigna.fr/certigna.crl")
|
201
|
+
body, error = SSLTest.send(:follow_crl_redirects, uri)
|
202
|
+
expect(body.bytesize).to equal 1152
|
203
|
+
expect(error).to be_nil
|
204
|
+
|
205
|
+
# Check cache status
|
206
|
+
cache = SSLTest.instance_variable_get('@crl_response_cache')
|
207
|
+
expect(cache.size).to equal 1
|
208
|
+
expect(cache.keys).to match_array [uri]
|
209
|
+
expect(cache[uri].keys).to match_array [:body, :expires, :etag, :last_mod]
|
210
|
+
expect(cache[uri][:expires]).to be > (Time.now + 3590)
|
211
|
+
|
212
|
+
# Make sure we return value from cache
|
213
|
+
body2, error2 = nil, nil
|
214
|
+
time = Benchmark.realtime { body2, error2 = SSLTest.send(:follow_crl_redirects, uri) }
|
215
|
+
expect(time).to be < 0.001 # no request
|
216
|
+
expect(body2).to be(body) # using cache
|
217
|
+
|
218
|
+
# Make sure we return cached value in case of 304
|
219
|
+
cache[uri][:expires] = Time.now # cache is now expired
|
220
|
+
body2, error2 = nil, nil
|
221
|
+
time = Benchmark.realtime { body2, error2 = SSLTest.send(:follow_crl_redirects, uri) }
|
222
|
+
expect(time).to be > 0.001 # a request is made
|
223
|
+
expect(body2).to be(body) # but we're still using cache because it's a 304
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
data/ssl-test.gemspec
CHANGED
@@ -6,7 +6,7 @@ require 'ssl-test'
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = "ssl-test"
|
8
8
|
spec.version = SSLTest::VERSION
|
9
|
-
spec.authors = ["Adrien Jarthon"]
|
9
|
+
spec.authors = ["Adrien Rey-Jarthon"]
|
10
10
|
spec.email = ["jobs@adrienjarthon.com"]
|
11
11
|
spec.summary = %q{Test website SSL certificate validity}
|
12
12
|
spec.homepage = "https://github.com/jarthod/ssl-test"
|
@@ -19,5 +19,5 @@ Gem::Specification.new do |spec|
|
|
19
19
|
|
20
20
|
spec.add_development_dependency "bundler", ">= 1.7"
|
21
21
|
spec.add_development_dependency "rake"
|
22
|
-
spec.add_development_dependency "
|
22
|
+
spec.add_development_dependency "rspec"
|
23
23
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ssl-test
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
- Adrien Jarthon
|
7
|
+
- Adrien Rey-Jarthon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-01-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -39,7 +39,7 @@ dependencies:
|
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: rspec
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
@@ -66,8 +66,11 @@ files:
|
|
66
66
|
- README.md
|
67
67
|
- Rakefile
|
68
68
|
- lib/ssl-test.rb
|
69
|
+
- lib/ssl-test/crl.rb
|
70
|
+
- lib/ssl-test/object_size.rb
|
71
|
+
- lib/ssl-test/ocsp.rb
|
72
|
+
- spec/ssl-test_spec.rb
|
69
73
|
- ssl-test.gemspec
|
70
|
-
- test/ssl-test_test.rb
|
71
74
|
homepage: https://github.com/jarthod/ssl-test
|
72
75
|
licenses:
|
73
76
|
- MIT
|
@@ -92,4 +95,4 @@ signing_key:
|
|
92
95
|
specification_version: 4
|
93
96
|
summary: Test website SSL certificate validity
|
94
97
|
test_files:
|
95
|
-
-
|
98
|
+
- spec/ssl-test_spec.rb
|
data/test/ssl-test_test.rb
DELETED
@@ -1,112 +0,0 @@
|
|
1
|
-
require "ssl-test"
|
2
|
-
require "minitest/autorun"
|
3
|
-
|
4
|
-
describe SSLTest do
|
5
|
-
|
6
|
-
describe '.test' do
|
7
|
-
it "returns no error on valid SNI website" do
|
8
|
-
valid, error, cert = SSLTest.test("https://www.mycs.com")
|
9
|
-
_(error).must_be_nil
|
10
|
-
_(valid).must_equal true
|
11
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
12
|
-
end
|
13
|
-
|
14
|
-
it "returns no error on valid SAN" do
|
15
|
-
valid, error, cert = SSLTest.test("https://1000-sans.badssl.com/")
|
16
|
-
_(error).must_be_nil
|
17
|
-
_(valid).must_equal true
|
18
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
19
|
-
end
|
20
|
-
|
21
|
-
it "returns no error when no CN" do
|
22
|
-
valid, error, cert = SSLTest.test("https://no-common-name.badssl.com/")
|
23
|
-
_(error).must_be_nil
|
24
|
-
_(valid).must_equal true
|
25
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
26
|
-
end
|
27
|
-
|
28
|
-
it "works with websites blocking http requests" do
|
29
|
-
valid, error, cert = SSLTest.test("https://obyava.ua")
|
30
|
-
_(error).must_be_nil
|
31
|
-
_(valid).must_equal true
|
32
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
33
|
-
end
|
34
|
-
|
35
|
-
it "returns error on self signed certificate" do
|
36
|
-
valid, error, cert = SSLTest.test("https://self-signed.badssl.com/")
|
37
|
-
_(error).must_equal "error code 18: self signed certificate"
|
38
|
-
_(valid).must_equal false
|
39
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
40
|
-
end
|
41
|
-
|
42
|
-
it "returns error on incomplete chain" do
|
43
|
-
valid, error, cert = SSLTest.test("https://incomplete-chain.badssl.com/")
|
44
|
-
_(error).must_equal "error code 20: unable to get local issuer certificate"
|
45
|
-
_(valid).must_equal false
|
46
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
47
|
-
end
|
48
|
-
|
49
|
-
it "returns error on untrusted root" do
|
50
|
-
valid, error, cert = SSLTest.test("https://untrusted-root.badssl.com/")
|
51
|
-
_(error).must_equal "error code 19: self signed certificate in certificate chain"
|
52
|
-
_(valid).must_equal false
|
53
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
54
|
-
end
|
55
|
-
|
56
|
-
it "returns error on invalid host" do
|
57
|
-
valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
|
58
|
-
assert error.include?('hostname "wrong.host.badssl.com" does not match the server certificate')
|
59
|
-
_(valid).must_equal false
|
60
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
61
|
-
end
|
62
|
-
|
63
|
-
it "returns error on expired cert" do
|
64
|
-
valid, error, cert = SSLTest.test("https://expired.badssl.com/")
|
65
|
-
_(error).must_equal "error code 10: certificate has expired"
|
66
|
-
_(valid).must_equal false
|
67
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
68
|
-
end
|
69
|
-
|
70
|
-
it "returns undetermined state on unhandled error" do
|
71
|
-
valid, error, cert = SSLTest.test("https://pijoinlrfgind.com")
|
72
|
-
_(error).must_equal "SSL certificate test failed: Failed to open TCP connection to pijoinlrfgind.com:443 (getaddrinfo: Name or service not known)"
|
73
|
-
_(valid).must_be_nil
|
74
|
-
_(cert).must_be_nil
|
75
|
-
end
|
76
|
-
|
77
|
-
it "stops on timeouts" do
|
78
|
-
valid, error, cert = SSLTest.test("https://updown.io", open_timeout: 0)
|
79
|
-
_(error).must_equal "SSL certificate test failed: Net::OpenTimeout"
|
80
|
-
_(valid).must_be_nil
|
81
|
-
_(cert).must_be_nil
|
82
|
-
end
|
83
|
-
|
84
|
-
it "returns error on revoked cert" do
|
85
|
-
valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
|
86
|
-
_(error).must_equal "SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)"
|
87
|
-
_(valid).must_equal false
|
88
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
89
|
-
end
|
90
|
-
|
91
|
-
it "stops following redirection after the limit for the revoked certs check" do
|
92
|
-
valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
|
93
|
-
_(error).must_equal "OCSP test couldn't be performed: Request failed (URI: http://ocsp.digicert.com): Too many redirections (> 0)"
|
94
|
-
_(valid).must_equal true
|
95
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
96
|
-
end
|
97
|
-
|
98
|
-
it "warns when the OCSP URI is missing" do
|
99
|
-
valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
|
100
|
-
_(error).must_equal "OCSP test couldn't be performed: Missing OCSP URI in authorityInfoAccess extension"
|
101
|
-
_(valid).must_equal true
|
102
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
103
|
-
end
|
104
|
-
|
105
|
-
it "warns when the authorityInfoAccess extension is missing" do
|
106
|
-
valid, error, cert = SSLTest.test("https://www.anonymisation.gov.pf")
|
107
|
-
_(error).must_equal "OCSP test couldn't be performed: Missing authorityInfoAccess extension"
|
108
|
-
_(valid).must_equal true
|
109
|
-
_(cert).must_be_instance_of OpenSSL::X509::Certificate
|
110
|
-
end
|
111
|
-
end
|
112
|
-
end
|