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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96222036799eb67b9c2f8356d8d838b9d35ea04df8622a43b4eee4ac48cb1b1c
4
- data.tar.gz: 06e7cc81b323295170afd44cd1009f8153ea84c662e6f80e5303d3ce0b267327
3
+ metadata.gz: 3192c4c66dc0345089108a47311eacba0e6b22ee1794896a9006bc2ff0fc7fce
4
+ data.tar.gz: 4306f6cc249d078ab07700ae5e42eac40d8387c6daefb41a1e43a43e169e6f29
5
5
  SHA512:
6
- metadata.gz: 101d9e53a4ce393be7445f3a7b05fc89836a7d72a6ba16eda8b157f94c63f7652f75b9aa70b883d86ec570c4d3f0e4cf77379d441665c8d5c53073838dc82966
7
- data.tar.gz: f16565a7be834ac9af6a8cefa0c39a820bab5cd40eecde444ac4bb48854b29510c9dfca83acc808972f6f63e71680c98a34beb356b96fd1c39e990cdc6673fc9
6
+ metadata.gz: 3ba176fda3fda4cf82f89c24a335fdb2e0ccdb7a735a228b2aae452afe7ebbc5b56cb6232d03a306315d9f14174a5f3ad8a383480b7180cee2ff7815e8471dfe
7
+ data.tar.gz: 7e7cf7ad82a36a541de9b65ad3bc30d4bf65577538249e6bf3980494d1829511c026b711571571c7b7ada5d60b70b4101776e3574734bb9fb4497db4e2fdb199
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # SSLTest [![Build Status](https://travis-ci.org/jarthod/ssl-test.svg?branch=master)](https://travis-ci.org/jarthod/ssl-test) [![Depfu](https://badges.depfu.com/badges/0d732c9cbec3fdaaac7c5ba5583269db/overview.svg)](https://depfu.com/github/jarthod/ssl-test)
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 may still be considered valid but with an error message:
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://sitewithnoOCSP.com"
62
+ valid, error, cert = SSLTest.test "https://sitewithnoOCSPorCRL.com"
61
63
  valid # => true
62
- error # => "OCSP test couldn't be performed: Missing OCSP URI in authorityInfoAccess extension"
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 performs a HEAD request using ruby `net/https` library and verifies the SSL status. It also hooks into the validation process to intercept the raw certificate for you.
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
- After that it queries the [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint to verify if the certificate has been revoked. OCSP responses are cached in memory so be careful if you try to validate millions of certificates.
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. Commit your changes (`git commit -am 'Add some feature'`)
94
- 4. Push to the branch (`git push origin my-new-feature`)
95
- 5. Create a new Pull Request
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 "rake/testtask"
2
+ require "rspec/core/rake_task"
3
3
 
4
- Rake::TestTask.new do |t|
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: :test
11
+ task default: :spec
@@ -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
- VERSION = "1.3.1".freeze
8
- OCSP_REQUEST_ERROR_CACHE_DURATION = 5 * 60
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
- if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
64
- issuer = chain[i + 1]
13
+ VERSION = -"1.4.0"
65
14
 
66
- digest = OpenSSL::Digest::SHA1.new
67
- certificate_id = OpenSSL::OCSP::CertificateId.new(cert, issuer, digest)
68
-
69
- request = OpenSSL::OCSP::Request.new
70
- request.add_certid certificate_id
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
- descriptions = authority_info_access.value.split("\n")
82
- ocsp = descriptions.find do |description|
83
- description.start_with?("OCSP")
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
- # https://tools.ietf.org/html/rfc3280#section-4.2.2.1
87
- # The id-ad-ocsp OID is used when revocation information for the certificate containing this extension is available using the Online Certificate Status Protocol (OCSP)
88
- return ocsp_soft_fail_return("Missing OCSP URI in authorityInfoAccess extension") unless ocsp
89
-
90
- ocsp_uri = URI(ocsp[/URI:(.*)/, 1])
91
- http_response, ocsp_request_error = follow_ocsp_redirects(ocsp_uri, request.to_der, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
92
- return ocsp_soft_fail_return("Request failed (URI: #{ocsp_uri}): #{ocsp_request_error}", unicity_key) unless http_response
93
-
94
- response = OpenSSL::OCSP::Response.new http_response.body
95
- # https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
96
- 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
97
- basic_response = response.basic
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
- # https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/Request.html#method-i-check_nonce
106
- return ocsp_soft_fail_return("Nonce check failed (URI: #{ocsp_uri})", unicity_key) unless request.check_nonce(basic_response) != 0
72
+ def flush_cache
73
+ @crl_response_cache = {}
74
+ @ocsp_response_cache = {}
75
+ @ocsp_request_error_cache = {}
76
+ end
107
77
 
108
- # https://ruby-doc.org/stdlib-2.3.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-status
109
- response_certificate_id, status, reason, revocation_time, _this_update, next_update, _extensions = basic_response.status.first
78
+ def logger= logger
79
+ @logger = logger
80
+ end
110
81
 
111
- return ocsp_soft_fail_return("Serial check failed (URI: #{ocsp_uri})", unicity_key) unless response_certificate_id.serial == certificate_id.serial
82
+ private
112
83
 
113
- @ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
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
- ocsp_response = @ocsp_response_cache[unicity_key]
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
@@ -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
@@ -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 "minitest"
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.3.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: 2020-04-26 00:00:00.000000000 Z
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: minitest
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
- - test/ssl-test_test.rb
98
+ - spec/ssl-test_spec.rb
@@ -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