module SSLTest

Constants

VERSION

Public Class Methods

cache_size() click to toggle source
# File lib/ssl-test.rb, line 58
def cache_size
  {
    crl: {
      lists: @crl_response_cache&.size || 0,
      bytes: ObjectSize.size(@crl_response_cache)
    },
    ocsp: {
      responses: @ocsp_response_cache&.size || 0,
      errors: @ocsp_request_error_cache&.size || 0,
      bytes: ObjectSize.size(@ocsp_response_cache) + ObjectSize.size(@ocsp_request_error_cache)
    }
  }
end
flush_cache() click to toggle source
# File lib/ssl-test.rb, line 72
def flush_cache
  @crl_response_cache = {}
  @ocsp_response_cache = {}
  @ocsp_request_error_cache = {}
end
logger=(logger) click to toggle source
# File lib/ssl-test.rb, line 78
def logger= logger
  @logger = logger
end
test(url, open_timeout: 5, read_timeout: 5, redirection_limit: 5) click to toggle source
# File lib/ssl-test.rb, line 16
def test url, open_timeout: 5, read_timeout: 5, redirection_limit: 5
  uri = URI.parse(url)
  return if uri.scheme != 'https'
  cert = failed_cert_reason = chain = nil

  @logger&.info { "SSLTest #{url} started" }
  http = Net::HTTP.new(uri.host, uri.port)
  http.open_timeout = open_timeout
  http.read_timeout = read_timeout
  http.use_ssl = true
  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 { }
    revoked, message, revocation_date = test_chain_revocation(chain, open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit)
    @logger&.info { "SSLTest #{url} finished: revoked=#{revoked} #{message}" }
    return [false, "SSL certificate revoked: #{message} (revocation date: #{revocation_date})", cert] if revoked
    return [true, "Revocation test couldn't be performed: #{message}", cert] if message
    return [true, nil, cert]
  rescue OpenSSL::SSL::SSLError => e
    error = e.message
    error = "error code %d: %s" % failed_cert_reason if failed_cert_reason
    if error =~ /certificate verify failed/
      domains = cert_domains(cert)
      if matching_domains(domains, uri.host).none?
        error = "hostname \"#{uri.host}\" does not match the server certificate (#{domains.join(', ')})"
      end
    end
    @logger&.info { "SSLTest #{url} finished: #{error}" }
    return [false, error, cert]
  rescue => e
    @logger&.error { "SSLTest #{url} failed: #{e.message}" }
    return [nil, "SSL certificate test failed: #{e.message}", cert]
  end
end

Private Class Methods

cert_domains(cert) click to toggle source
# File lib/ssl-test.rb, line 120
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
cert_field_to_hash(field) click to toggle source
# File lib/ssl-test.rb, line 113
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
matching_domains(domains, hostname) click to toggle source
# File lib/ssl-test.rb, line 128
def matching_domains domains, hostname
  domains.map {|s| Regexp.new("\A#{Regexp.escape(s).gsub('\*', '[^.]+')}\z") }
    .select {|domain| domain.match?(hostname) }
end
test_chain_revocation(chain, **options) click to toggle source

docs.ruby-lang.org/en/2.2.0/OpenSSL/OCSP.html stackoverflow.com/questions/16244084/how-to-programmatically-check-if-a-certificate-has-been-revoked#answer-16257470 Returns an array with [certificate_revoked?, error_reason, revocation_date]

# File lib/ssl-test.rb, line 87
def test_chain_revocation chain, **options
  # Test each certificates in the chain except the last one (root cert),
  # which can only be revoked by removing it from the OS.
  chain[0..-2].each_with_index do |cert, i|
    @logger&.debug { "SSLTest + test_chain_revocation: #{cert_field_to_hash(cert.subject)['CN']}" }

    # Try with OCSP first
    ocsp_result = test_ocsp_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
    @logger&.debug { "SSLTest   + OCSP: #{ocsp_result}" }
    next if ocsp_result == :ocsp_ok # passed, go to next cert
    return ocsp_result if ocsp_result[0] == true # revoked

    # Otherwise it means there was an error so let's try with CRL instead
    crl_result = test_crl_revocation(cert, issuer: chain[i + 1], chain: chain, **options)
    @logger&.debug { "SSLTest   + CRL: #{crl_result}" }
    next if crl_result == :crl_ok # passed, go to next cert
    return crl_result if crl_result[0] == true # revoked

    # If both method failed, return a soft fail with a combination of both error messages
    return [false, "OCSP: #{ocsp_result[1]}, CRL: #{crl_result[1]}", nil]
  end

  # If all test passed, the certificate is not revoked
  [false, nil, nil]
end