Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check revoked certificates with OCSP (#2) #3

Merged
merged 25 commits into from
Apr 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0b28bc6
Check revoked certificates with OCSP (#2)
FabienChaynes Jun 1, 2019
2fbfe01
Check certificate_id. Comments. Use constant for failing return.
FabienChaynes Jun 2, 2019
86984f4
Bump version number
FabienChaynes Jun 9, 2019
308ae89
Check OCSP response signature.
FabienChaynes Jun 9, 2019
3503eb0
Specific error message when the OCSP check raised.
FabienChaynes Jun 9, 2019
8878952
Fix OCSP response verification.
FabienChaynes Apr 19, 2020
d6b3f29
Narow rescue.
FabienChaynes Apr 19, 2020
081dc1c
Improve clarity.
FabienChaynes Apr 19, 2020
b1b4fb0
Improve OCSP soft fail by returning reason.
FabienChaynes Apr 20, 2020
1511899
Check all the chain.
FabienChaynes Apr 20, 2020
66f6e59
Return cert whem OCSP test failed.
FabienChaynes Apr 21, 2020
b91d3a6
Cache OCSP responses.
FabienChaynes Apr 21, 2020
d8a10d5
- Remove useless cert argument
FabienChaynes Apr 21, 2020
630bf94
Rename variable.
FabienChaynes Apr 21, 2020
89e9047
Use string for hash key.
FabienChaynes Apr 22, 2020
0b079df
Make internal methods private.
FabienChaynes Apr 22, 2020
aad8a85
Fix spec failing because of cache.
FabienChaynes Apr 22, 2020
c987c5f
Fix specs.
FabienChaynes Apr 22, 2020
3ec6074
Fix spec.
FabienChaynes Apr 22, 2020
32b5054
Handle missing authorityInfoAccess extension.
FabienChaynes Apr 22, 2020
e5c9bdb
Add specs.
FabienChaynes Apr 22, 2020
7046a79
Merge branch 'master' into 2-detect-revoked-certificates
jarthod Apr 25, 2020
0a5a614
Add more details to readme
jarthod Apr 25, 2020
afd8b0b
minor readme changes
jarthod Apr 25, 2020
7fc0d0d
wrong formating
jarthod Apr 25, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,27 @@ cert # => nil
```
Default timeout values are 5 seconds each (open and read)

Revoked certificates are detected using [OCSP](https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol) endpoint:
```ruby
valid, error, cert = SSLTest.test "https://revoked.badssl.com"
valid # => false
error # => "SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)"
cert # => #<OpenSSL::X509::Certificate...>
```

If the OCSP endpoint is invalid or unreachable the certificate may still be considered valid but with an error message:
```ruby
valid, error, cert = SSLTest.test "https://sitewithnoOCSP.com"
valid # => true
error # => "OCSP test couldn't be performed: Missing OCSP URI in authorityInfoAccess extension"
cert # => #<OpenSSL::X509::Certificate...>
```

## How it works

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.
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.

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.

### What kind of errors will SSLTest detect

Expand All @@ -61,13 +79,11 @@ Pretty much the same errors `curl` will:
- Untrusted root (if your system is up-to-date)
- And more...

### GOTCHA: errors SSLTest will NOT detect

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).
But also **revoked certs** like most browsers (not handled by `curl`)

Here is an example of website with a revoked certificate: https://revoked.badssl.com/
## Changelog

Any contribution to add this feature is greatly appreciated :)
* 1.3.0 - 2020-04-25: Added revoked cert detection using OCSP (#3)

## Contributing

Expand Down
185 changes: 168 additions & 17 deletions lib/ssl-test.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
require "net/http"
require "net/https"
require "openssl"
require "uri"

module SSLTest
VERSION = "1.2.0"
VERSION = "1.3.0".freeze

def self.test url, open_timeout: 5, read_timeout: 5
def self.test url, open_timeout: 5, read_timeout: 5, redirection_limit: 5
uri = URI.parse(url)
return if uri.scheme != 'https'
cert = failed_cert_reason = nil
cert = failed_cert_reason = chain = nil

http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = open_timeout
Expand All @@ -15,12 +18,17 @@ def self.test url, open_timeout: 5, read_timeout: 5
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.verify_callback = -> (verify_ok, store_context) {
cert = store_context.current_cert
chain = store_context.chain
failed_cert_reason = [store_context.error, store_context.error_string] if store_context.error != 0
verify_ok
}

begin
http.start { }
failed, revoked, message, revocation_date = test_ocsp_revocation(chain, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
return [nil, "OCSP test failed: #{message}", cert] if failed
return [false, "SSL certificate revoked: #{message} (revocation date: #{revocation_date})", cert] if revoked
return [true, "OCSP test couldn't be performed: #{message}", cert] if message
return [true, nil, cert]
rescue OpenSSL::SSL::SSLError => e
error = e.message
Expand All @@ -37,23 +45,166 @@ def self.test url, open_timeout: 5, read_timeout: 5
end
end

def self.cert_field_to_hash field
field.to_a.each.with_object({}) do |v, h|
v = v.to_a
h[v[0]] = v[1].encode('UTF-8', undef: :replace, invalid: :replace)
# https://docs.ruby-lang.org/en/2.2.0/OpenSSL/OCSP.html
# https://stackoverflow.com/questions/16244084/how-to-programmatically-check-if-a-certificate-has-been-revoked#answer-16257470
# Returns an array with [ocsp_check_failed, certificate_revoked, error_reason, revocation_date]
def self.test_ocsp_revocation chain, open_timeout: 5, read_timeout: 5, redirection_limit: 5
@ocsp_response_cache ||= {}
jarthod marked this conversation as resolved.
Show resolved Hide resolved
chain[0..-2].each_with_index do |cert, i|
# https://tools.ietf.org/html/rfc5280#section-4.1.2.2
# 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)
unicity_key = "#{cert.issuer}/#{cert.serial}"

if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
issuer = chain[i + 1]

digest = OpenSSL::Digest::SHA1.new
certificate_id = OpenSSL::OCSP::CertificateId.new(cert, issuer, digest)

request = OpenSSL::OCSP::Request.new
request.add_certid certificate_id
request.add_nonce

authority_info_access = cert.extensions.find do |extension|
extension.oid == "authorityInfoAccess"
end

# https://tools.ietf.org/html/rfc3280#section-4.2.2.1
# The authority information access extension [...] may be included in end entity or CA certificates, and it MUST be non-critical.
return ocsp_soft_fail_return("Missing authorityInfoAccess extension") unless authority_info_access

descriptions = authority_info_access.value.split("\n")
ocsp = descriptions.find do |description|
description.start_with?("OCSP")
end

# https://tools.ietf.org/html/rfc3280#section-4.2.2.1
# 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)
return ocsp_soft_fail_return("Missing OCSP URI in authorityInfoAccess extension") unless ocsp

ocsp_uri = URI(ocsp[/URI:(.*)/, 1])
http_response = follow_ocsp_redirects(ocsp_uri, request.to_der, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
return ocsp_soft_fail_return("OCSP response request failed") unless http_response

response = OpenSSL::OCSP::Response.new http_response.body
# https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
return ocsp_soft_fail_return("OCSP response failed: #{ocsp_response_status_to_string(response.status)}") unless response.status == OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL
basic_response = response.basic

# Check the response signature
store = OpenSSL::X509::Store.new
store.set_default_paths
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-verify
return ocsp_soft_fail_return("OCSP response signature verification failed") unless basic_response.verify(chain, store)

# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP/Request.html#method-i-check_nonce
return ocsp_soft_fail_return("OCSP response nonce check failed") unless request.check_nonce(basic_response) != 0

# https://ruby-doc.org/stdlib-2.3.0/libdoc/openssl/rdoc/OpenSSL/OCSP/BasicResponse.html#method-i-status
response_certificate_id, status, reason, revocation_time, this_update, next_update, _extensions = basic_response.status.first
jarthod marked this conversation as resolved.
Show resolved Hide resolved

return ocsp_soft_fail_return("OCSP response serial check failed") unless response_certificate_id.serial == certificate_id.serial

FabienChaynes marked this conversation as resolved.
Show resolved Hide resolved
@ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
end

ocsp_response = @ocsp_response_cache[unicity_key]

return [false, true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
end
[false, false, nil, nil]
rescue => e
return [true, nil, e.message, nil]
end

def self.cert_domains cert
(Array(cert_field_to_hash(cert.subject)['CN']) +
cert_field_to_hash(cert.extensions)['subjectAltName'].split(/\s*,\s*/))
.compact
.map {|s| s.gsub(/^DNS:/, '') }
.uniq
end
class << self
private

def self.matching_domains domains, hostname
domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
.select {|domain| domain.match?(hostname) }
def cert_field_to_hash field
field.to_a.each.with_object({}) do |v, h|
v = v.to_a
h[v[0]] = v[1].encode('UTF-8', undef: :replace, invalid: :replace)
end
end

def cert_domains cert
(Array(cert_field_to_hash(cert.subject)['CN']) +
cert_field_to_hash(cert.extensions)['subjectAltName'].split(/\s*,\s*/))
.compact
.map {|s| s.gsub(/^DNS:/, '') }
.uniq
end

def matching_domains domains, hostname
domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
.select {|domain| domain.match?(hostname) }
end

def follow_ocsp_redirects(uri, data, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
return nil if redirection_limit == 0

path = uri.path == "" ? "/" : uri.path
http = Net::HTTP.new(uri.hostname, uri.port)
http.open_timeout = open_timeout
http.read_timeout = read_timeout

http_response = http.post(path, data, "content-type" => "application/ocsp-request")
case http_response
when Net::HTTPSuccess
http_response
when Net::HTTPRedirection
follow_ocsp_redirects(URI(http_response["location"]), data, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit -1)
else
nil
end
end

# https://ruby-doc.org/stdlib-2.6.3/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
def ocsp_response_status_to_string(response_status)
case response_status
when OpenSSL::OCSP::RESPONSE_STATUS_INTERNALERROR
"Internal error in issuer"
when OpenSSL::OCSP::RESPONSE_STATUS_MALFORMEDREQUEST
"Illegal confirmation request"
when OpenSSL::OCSP::RESPONSE_STATUS_SIGREQUIRED
"You must sign the request and resubmit"
when OpenSSL::OCSP::RESPONSE_STATUS_TRYLATER
"Try again later"
when OpenSSL::OCSP::RESPONSE_STATUS_UNAUTHORIZED
"Your request is unauthorized"
else
"Unknown reason"
end
end

def ocsp_soft_fail_return(reason)
[false, false, reason, nil].freeze
end

def revocation_reason_to_string(revocation_reason)
# https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/OCSP.html#constants-list
case revocation_reason
when OpenSSL::OCSP::REVOKED_STATUS_AFFILIATIONCHANGED
"The certificate subject's name or other information changed"
when OpenSSL::OCSP::REVOKED_STATUS_CACOMPROMISE
"This CA certificate was revoked due to a key compromise"
when OpenSSL::OCSP::REVOKED_STATUS_CERTIFICATEHOLD
"The certificate is on hold"
when OpenSSL::OCSP::REVOKED_STATUS_CESSATIONOFOPERATION
"The certificate is no longer needed"
when OpenSSL::OCSP::REVOKED_STATUS_KEYCOMPROMISE
"The certificate was revoked due to a key compromise"
when OpenSSL::OCSP::REVOKED_STATUS_NOSTATUS
"The certificate was revoked for an unknown reason"
when OpenSSL::OCSP::REVOKED_STATUS_REMOVEFROMCRL
"The certificate was previously on hold and should now be removed from the CRL"
when OpenSSL::OCSP::REVOKED_STATUS_SUPERSEDED
"The certificate was superseded by a new certificate"
when OpenSSL::OCSP::REVOKED_STATUS_UNSPECIFIED
"The certificate was revoked for an unspecified reason"
else
"Unknown reason"
end
end
end
end
38 changes: 29 additions & 9 deletions test/ssl-test_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@

it "returns error on invalid host" do
valid, error, cert = SSLTest.test("https://wrong.host.badssl.com/")
error.must_equal 'hostname "wrong.host.badssl.com" does not match the server certificate (*.badssl.com, badssl.com)'
error.must_equal 'hostname "wrong.host.badssl.com" does not match the server certificate'
valid.must_equal false
cert.must_be_instance_of OpenSSL::X509::Certificate
end
Expand All @@ -81,12 +81,32 @@
cert.must_be_nil
end

# Not implemented yet
# it "returns error on revoked cert" do
# valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
# error.must_equal "error code XX: certificate has been revoked"
# valid.must_equal false
# cert.must_be_instance_of OpenSSL::X509::Certificate
# end
it "returns error on revoked cert" do
valid, error, cert = SSLTest.test("https://revoked.badssl.com/")
error.must_equal "SSL certificate revoked: The certificate was revoked for an unknown reason (revocation date: 2019-10-07 20:30:39 UTC)"
valid.must_equal false
cert.must_be_instance_of OpenSSL::X509::Certificate
end

it "stops following redirection after the limit for the revoked certs check" do
valid, error, cert = SSLTest.test("https://github.com/", redirection_limit: 0)
error.must_equal "OCSP test couldn't be performed: OCSP response request failed"
valid.must_equal true
cert.must_be_instance_of OpenSSL::X509::Certificate
end

it "warns when the OCSP URI is missing" do
valid, error, cert = SSLTest.test("https://www.demarches-simplifiees.fr")
error.must_equal "OCSP test couldn't be performed: Missing OCSP URI in authorityInfoAccess extension"
valid.must_equal true
cert.must_be_instance_of OpenSSL::X509::Certificate
end

it "warns when the authorityInfoAccess extension is missing" do
valid, error, cert = SSLTest.test("https://www.anonymisation.gov.pf")
error.must_equal "OCSP test couldn't be performed: Missing authorityInfoAccess extension"
valid.must_equal true
cert.must_be_instance_of OpenSSL::X509::Certificate
end
end
end
end