ssl-test 1.2.0 → 1.3.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: 3f6f73059bf9880985e8564282104fa677ecf3016872948d4047d47589f4ff12
4
- data.tar.gz: 2c5580091f64589290c5bda90b25172c5d78dab67c9a1a40829e3e2cc35bc98f
3
+ metadata.gz: 2e0ef293b9ca10c582a1965de46c2e60ee83abc9b21bed73f25f1714ba2765e5
4
+ data.tar.gz: 539b4d8d4cd53eaf33e3c795b9567ae7409be393888e7be1ee22c03536d83219
5
5
  SHA512:
6
- metadata.gz: 280ea4637845f7df589dfbc07ea51459a5fefaa36b36bb17614beb6fb809bc71f5d36a70e3ccc7e8fba61383b5ad0ba24e9fd49f066636792968ac6f132e5793
7
- data.tar.gz: dc96640201b5098695be42467d052f592a2d3bf59d38e9cfbe1028bf7984226d46ec058ad35381383886cb84e7e348af07bf8429c67eed7d4fcafc8c89c3fe8f
6
+ metadata.gz: 4430b6b22196a66d67a82a34ea3ffbf826c85bd2be832fed688ad29bcfe1a0c04663b4bd00ec4d32d62e4fc502fd802d5ee573d094fde2e469de932971882be3
7
+ data.tar.gz: f72393d3d6f44e086f960707d3f7ae17588f728a121a1ed46929a5d0f5418ee7a109bb5975566cfa9f34d8b6adfb06d77e24df5c4881e72028a38a5b8b382129
data/README.md CHANGED
@@ -47,9 +47,27 @@ 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:
51
+ ```ruby
52
+ valid, error, cert = SSLTest.test "https://revoked.badssl.com"
53
+ valid # => false
54
+ error # => "SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)"
55
+ cert # => #<OpenSSL::X509::Certificate...>
56
+ ```
57
+
58
+ If the OCSP endpoint is invalid or unreachable the certificate may still be considered valid but with an error message:
59
+ ```ruby
60
+ valid, error, cert = SSLTest.test "https://sitewithnoOCSP.com"
61
+ valid # => true
62
+ error # => "OCSP test couldn't be performed: Missing OCSP URI in authorityInfoAccess extension"
63
+ cert # => #<OpenSSL::X509::Certificate...>
64
+ ```
65
+
50
66
  ## How it works
51
67
 
52
- SSLTester simply 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.
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.
69
+
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.
53
71
 
54
72
  ### What kind of errors will SSLTest detect
55
73
 
@@ -61,13 +79,11 @@ Pretty much the same errors `curl` will:
61
79
  - Untrusted root (if your system is up-to-date)
62
80
  - And more...
63
81
 
64
- ### GOTCHA: errors SSLTest will NOT detect
65
-
66
- There is a spefic kind or error this code will **NOT** detect: *revoked certificates*. This is much more complex to handle because it needs an up to date database of revoked certs to check with. This is implemented in most modern browsers but the results vary greatly (chrome ignores this for example).
82
+ But also **revoked certs** like most browsers (not handled by `curl`)
67
83
 
68
- Here is an example of website with a revoked certificate: https://revoked.badssl.com/
84
+ ## Changelog
69
85
 
70
- Any contribution to add this feature is greatly appreciated :)
86
+ * 1.3.0 - 2020-04-25: Added revoked cert detection using OCSP (#3)
71
87
 
72
88
  ## Contributing
73
89
 
@@ -1,12 +1,15 @@
1
+ require "net/http"
1
2
  require "net/https"
3
+ require "openssl"
4
+ require "uri"
2
5
 
3
6
  module SSLTest
4
- VERSION = "1.2.0"
7
+ VERSION = "1.3.0".freeze
5
8
 
6
- def self.test url, open_timeout: 5, read_timeout: 5
9
+ def self.test url, open_timeout: 5, read_timeout: 5, redirection_limit: 5
7
10
  uri = URI.parse(url)
8
11
  return if uri.scheme != 'https'
9
- cert = failed_cert_reason = nil
12
+ cert = failed_cert_reason = chain = nil
10
13
 
11
14
  http = Net::HTTP.new(uri.host, uri.port)
12
15
  http.open_timeout = open_timeout
@@ -15,12 +18,17 @@ module SSLTest
15
18
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
16
19
  http.verify_callback = -> (verify_ok, store_context) {
17
20
  cert = store_context.current_cert
21
+ chain = store_context.chain
18
22
  failed_cert_reason = [store_context.error, store_context.error_string] if store_context.error != 0
19
23
  verify_ok
20
24
  }
21
25
 
22
26
  begin
23
27
  http.start { }
28
+ failed, revoked, message, revocation_date = test_ocsp_revocation(chain, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
29
+ return [nil, "OCSP test failed: #{message}", cert] if failed
30
+ return [false, "SSL certificate revoked: #{message} (revocation date: #{revocation_date})", cert] if revoked
31
+ return [true, "OCSP test couldn't be performed: #{message}", cert] if message
24
32
  return [true, nil, cert]
25
33
  rescue OpenSSL::SSL::SSLError => e
26
34
  error = e.message
@@ -37,23 +45,166 @@ module SSLTest
37
45
  end
38
46
  end
39
47
 
40
- def self.cert_field_to_hash field
41
- field.to_a.each.with_object({}) do |v, h|
42
- v = v.to_a
43
- h[v[0]] = v[1].encode('UTF-8', undef: :replace, invalid: :replace)
48
+ # https://docs.ruby-lang.org/en/2.2.0/OpenSSL/OCSP.html
49
+ # https://stackoverflow.com/questions/16244084/how-to-programmatically-check-if-a-certificate-has-been-revoked#answer-16257470
50
+ # Returns an array with [ocsp_check_failed, certificate_revoked, error_reason, revocation_date]
51
+ def self.test_ocsp_revocation chain, open_timeout: 5, read_timeout: 5, redirection_limit: 5
52
+ @ocsp_response_cache ||= {}
53
+ chain[0..-2].each_with_index do |cert, i|
54
+ # https://tools.ietf.org/html/rfc5280#section-4.1.2.2
55
+ # 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)
56
+ unicity_key = "#{cert.issuer}/#{cert.serial}"
57
+
58
+ if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
59
+ issuer = chain[i + 1]
60
+
61
+ digest = OpenSSL::Digest::SHA1.new
62
+ certificate_id = OpenSSL::OCSP::CertificateId.new(cert, issuer, digest)
63
+
64
+ request = OpenSSL::OCSP::Request.new
65
+ request.add_certid certificate_id
66
+ request.add_nonce
67
+
68
+ authority_info_access = cert.extensions.find do |extension|
69
+ extension.oid == "authorityInfoAccess"
70
+ end
71
+
72
+ # https://tools.ietf.org/html/rfc3280#section-4.2.2.1
73
+ # The authority information access extension [...] may be included in end entity or CA certificates, and it MUST be non-critical.
74
+ return ocsp_soft_fail_return("Missing authorityInfoAccess extension") unless authority_info_access
75
+
76
+ descriptions = authority_info_access.value.split("\n")
77
+ ocsp = descriptions.find do |description|
78
+ description.start_with?("OCSP")
79
+ end
80
+
81
+ # https://tools.ietf.org/html/rfc3280#section-4.2.2.1
82
+ # 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)
83
+ return ocsp_soft_fail_return("Missing OCSP URI in authorityInfoAccess extension") unless ocsp
84
+
85
+ ocsp_uri = URI(ocsp[/URI:(.*)/, 1])
86
+ http_response = follow_ocsp_redirects(ocsp_uri, request.to_der, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
87
+ return ocsp_soft_fail_return("OCSP response request failed") unless http_response
88
+
89
+ response = OpenSSL::OCSP::Response.new http_response.body
90
+ # https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
91
+ return ocsp_soft_fail_return("OCSP response failed: #{ocsp_response_status_to_string(response.status)}") unless response.status == OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL
92
+ basic_response = response.basic
93
+
94
+ # Check the response signature
95
+ store = OpenSSL::X509::Store.new
96
+ store.set_default_paths
97
+ # https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-verify
98
+ return ocsp_soft_fail_return("OCSP response signature verification failed") unless basic_response.verify(chain, store)
99
+
100
+ # https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/Request.html#method-i-check_nonce
101
+ return ocsp_soft_fail_return("OCSP response nonce check failed") unless request.check_nonce(basic_response) != 0
102
+
103
+ # https://ruby-doc.org/stdlib-2.3.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-status
104
+ response_certificate_id, status, reason, revocation_time, this_update, next_update, _extensions = basic_response.status.first
105
+
106
+ return ocsp_soft_fail_return("OCSP response serial check failed") unless response_certificate_id.serial == certificate_id.serial
107
+
108
+ @ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
109
+ end
110
+
111
+ ocsp_response = @ocsp_response_cache[unicity_key]
112
+
113
+ return [false, true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
44
114
  end
115
+ [false, false, nil, nil]
116
+ rescue => e
117
+ return [true, nil, e.message, nil]
45
118
  end
46
119
 
47
- def self.cert_domains cert
48
- (Array(cert_field_to_hash(cert.subject)['CN']) +
49
- cert_field_to_hash(cert.extensions)['subjectAltName'].split(/\s*,\s*/))
50
- .compact
51
- .map {|s| s.gsub(/^DNS:/, '') }
52
- .uniq
53
- end
120
+ class << self
121
+ private
54
122
 
55
- def self.matching_domains domains, hostname
56
- domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
57
- .select {|domain| domain.match?(hostname) }
123
+ def cert_field_to_hash field
124
+ field.to_a.each.with_object({}) do |v, h|
125
+ v = v.to_a
126
+ h[v[0]] = v[1].encode('UTF-8', undef: :replace, invalid: :replace)
127
+ end
128
+ end
129
+
130
+ def cert_domains cert
131
+ (Array(cert_field_to_hash(cert.subject)['CN']) +
132
+ cert_field_to_hash(cert.extensions)['subjectAltName'].split(/\s*,\s*/))
133
+ .compact
134
+ .map {|s| s.gsub(/^DNS:/, '') }
135
+ .uniq
136
+ end
137
+
138
+ def matching_domains domains, hostname
139
+ domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
140
+ .select {|domain| domain.match?(hostname) }
141
+ end
142
+
143
+ def follow_ocsp_redirects(uri, data, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
144
+ return nil if redirection_limit == 0
145
+
146
+ path = uri.path == "" ? "/" : uri.path
147
+ http = Net::HTTP.new(uri.hostname, uri.port)
148
+ http.open_timeout = open_timeout
149
+ http.read_timeout = read_timeout
150
+
151
+ http_response = http.post(path, data, "content-type" => "application/ocsp-request")
152
+ case http_response
153
+ when Net::HTTPSuccess
154
+ http_response
155
+ when Net::HTTPRedirection
156
+ follow_ocsp_redirects(URI(http_response["location"]), data, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit -1)
157
+ else
158
+ nil
159
+ end
160
+ end
161
+
162
+ # https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
163
+ def ocsp_response_status_to_string(response_status)
164
+ case response_status
165
+ when OpenSSL::OCSP::RESPONSE_STATUS_INTERNALERROR
166
+ "Internal error in issuer"
167
+ when OpenSSL::OCSP::RESPONSE_STATUS_MALFORMEDREQUEST
168
+ "Illegal confirmation request"
169
+ when OpenSSL::OCSP::RESPONSE_STATUS_SIGREQUIRED
170
+ "You must sign the request and resubmit"
171
+ when OpenSSL::OCSP::RESPONSE_STATUS_TRYLATER
172
+ "Try again later"
173
+ when OpenSSL::OCSP::RESPONSE_STATUS_UNAUTHORIZED
174
+ "Your request is unauthorized"
175
+ else
176
+ "Unknown reason"
177
+ end
178
+ end
179
+
180
+ def ocsp_soft_fail_return(reason)
181
+ [false, false, reason, nil].freeze
182
+ end
183
+
184
+ def revocation_reason_to_string(revocation_reason)
185
+ # https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
186
+ case revocation_reason
187
+ when OpenSSL::OCSP::REVOKED_STATUS_AFFILIATIONCHANGED
188
+ "The certificate subject's name or other information changed"
189
+ when OpenSSL::OCSP::REVOKED_STATUS_CACOMPROMISE
190
+ "This CA certificate was revoked due to a key compromise"
191
+ when OpenSSL::OCSP::REVOKED_STATUS_CERTIFICATEHOLD
192
+ "The certificate is on hold"
193
+ when OpenSSL::OCSP::REVOKED_STATUS_CESSATIONOFOPERATION
194
+ "The certificate is no longer needed"
195
+ when OpenSSL::OCSP::REVOKED_STATUS_KEYCOMPROMISE
196
+ "The certificate was revoked due to a key compromise"
197
+ when OpenSSL::OCSP::REVOKED_STATUS_NOSTATUS
198
+ "The certificate was revoked for an unknown reason"
199
+ when OpenSSL::OCSP::REVOKED_STATUS_REMOVEFROMCRL
200
+ "The certificate was previously on hold and should now be removed from the CRL"
201
+ when OpenSSL::OCSP::REVOKED_STATUS_SUPERSEDED
202
+ "The certificate was superseded by a new certificate"
203
+ when OpenSSL::OCSP::REVOKED_STATUS_UNSPECIFIED
204
+ "The certificate was revoked for an unspecified reason"
205
+ else
206
+ "Unknown reason"
207
+ end
208
+ end
58
209
  end
59
210
  end
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
18
  spec.require_paths = ["lib"]
19
19
 
20
- spec.add_development_dependency "bundler", "~> 1.7"
20
+ spec.add_development_dependency "bundler", ">= 1.7"
21
21
  spec.add_development_dependency "rake"
22
22
  spec.add_development_dependency "minitest"
23
23
  end
@@ -39,6 +39,13 @@ describe SSLTest do
39
39
  cert.must_be_instance_of OpenSSL::X509::Certificate
40
40
  end
41
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
+
42
49
  it "returns error on untrusted root" do
43
50
  valid, error, cert = SSLTest.test("https://untrusted-root.badssl.com/")
44
51
  error.must_equal "error code 20: unable to get local issuer certificate"
@@ -48,7 +55,7 @@ describe SSLTest do
48
55
 
49
56
  it "returns error on invalid host" do
50
57
  valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
51
- error.must_equal 'hostname "wrong.host.badssl.com" does not match the server certificate (*.badssl.com, badssl.com)'
58
+ error.must_equal 'hostname "wrong.host.badssl.com" does not match the server certificate'
52
59
  valid.must_equal false
53
60
  cert.must_be_instance_of OpenSSL::X509::Certificate
54
61
  end
@@ -74,12 +81,32 @@ describe SSLTest do
74
81
  cert.must_be_nil
75
82
  end
76
83
 
77
- # Not implemented yet
78
- # it "returns error on revoked cert" do
79
- # valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
80
- # error.must_equal "error code XX: certificate has been revoked"
81
- # valid.must_equal false
82
- # cert.must_be_instance_of OpenSSL::X509::Certificate
83
- # end
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: OCSP response request failed"
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
84
111
  end
85
- end
112
+ end
metadata CHANGED
@@ -1,27 +1,27 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ssl-test
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrien Jarthon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-03-04 00:00:00.000000000 Z
11
+ date: 2020-04-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.7'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.7'
27
27
  - !ruby/object:Gem::Dependency
@@ -87,8 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
87
  - !ruby/object:Gem::Version
88
88
  version: '0'
89
89
  requirements: []
90
- rubyforge_project:
91
- rubygems_version: 2.7.3
90
+ rubygems_version: 3.1.2
92
91
  signing_key:
93
92
  specification_version: 4
94
93
  summary: Test website SSL certificate validity