class Mongo::Socket::OcspVerifier

OCSP endpoint verifier.

After a TLS connection is established, this verifier inspects the certificate presented by the server, and if the certificate contains an OCSP URI, performs the OCSP status request to the specified URI (following up to 5 redirects) to verify the certificate status.

@see ruby-doc.org/stdlib/libdoc/openssl/rdoc/OpenSSL/OCSP.html

@api private

Attributes

ca_cert[R]
cert[R]
cert_store[R]
host_name[R]
options[R]

Public Class Methods

new(host_name, cert, ca_cert, cert_store, **opts) click to toggle source

@param [ String ] host_name The host name being verified, for

diagnostic output.

@param [ OpenSSL::X509::Certificate ] cert The certificate presented by

the server at host_name.

@param [ OpenSSL::X509::Certificate ] ca_cert The CA certificate

presented by the server or resolved locally from the server
certificate.

@param [ OpenSSL::X509::Store ] cert_store The certificate store to

use for verifying OCSP response. This should be the same store as
used in SSLContext used with the SSLSocket that we are verifying the
certificate for. This must NOT be the CA certificate provided by
the server (i.e. anything taken out of peer_cert) - otherwise the
server would dictate which CA authorities the client trusts.
# File lib/mongo/socket/ocsp_verifier.rb, line 48
def initialize(host_name, cert, ca_cert, cert_store, **opts)
  @host_name = host_name
  @cert = cert
  @ca_cert = ca_cert
  @cert_store = cert_store
  @options = opts
end

Public Instance Methods

cert_id() click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 88
def cert_id
  @cert_id ||= OpenSSL::OCSP::CertificateId.new(
    cert,
    ca_cert,
    OpenSSL::Digest::SHA1.new,
  )
end
ocsp_uris() click to toggle source

@return [ Array<String> ] OCSP URIs in the specified server certificate.

# File lib/mongo/socket/ocsp_verifier.rb, line 67
def ocsp_uris
  @ocsp_uris ||= begin
    # https://tools.ietf.org/html/rfc3546#section-2.3
    # prohibits multiple extensions with the same oid.
    ext = cert.extensions.detect do |ext|
      ext.oid == 'authorityInfoAccess'
    end

    if ext
      # Our test certificates have multiple OCSP URIs.
      ext.value.split("\n").select do |line|
        line.start_with?('OCSP - URI:')
      end.map do |line|
        line.split(':', 2).last
      end
    else
      []
    end
  end
end
timeout() click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 62
def timeout
  options[:timeout] || 5
end
verify() click to toggle source

@return [ true | false ] Whether the certificate was verified.

@raise [ Error::ServerCertificateRevoked ] If the certificate was

definitively revoked.
# File lib/mongo/socket/ocsp_verifier.rb, line 119
def verify
  handle_exceptions do
    return false if ocsp_uris.empty?

    resp, errors = do_verify
    return_ocsp_response(resp, errors)
  end
end
verify_with_cache() click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 96
def verify_with_cache
  handle_exceptions do
    return false if ocsp_uris.empty?

    resp = OcspCache.get(cert_id)
    if resp
      return return_ocsp_response(resp)
    end

    resp, errors = do_verify

    if resp
      OcspCache.set(cert_id, resp)
    end

    return_ocsp_response(resp, errors)
  end
end

Private Instance Methods

do_verify() click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 130
def do_verify
  # This synchronized array contains definitive pass/fail responses
  # obtained from the responders. We'll take the first one but due to
  # concurrency multiple responses may be produced and queued.
  @resp_queue = Queue.new

  # This synchronized array contains strings, one per responder, that
  # explain why each responder hasn't produced a definitive response.
  # These are concatenated and logged if none of the responders produced
  # a definitive respnose, or if the main thread times out waiting for
  # a definitive response (in which case some of the worker threads'
  # diagnostics may be logged and some may not).
  @resp_errors = Queue.new

  @req = OpenSSL::OCSP::Request.new
  @req.add_certid(cert_id)
  @req.add_nonce
  @serialized_req = @req.to_der

  @outstanding_requests = ocsp_uris.count
  @outstanding_requests_lock = Mutex.new

  threads = ocsp_uris.map do |uri|
    Thread.new do
      verify_one_responder(uri)
    end
  end

  resp = begin
    ::Timeout.timeout(timeout) do
      @resp_queue.shift
    end
  rescue ::Timeout::Error
    nil
  end

  threads.map(&:kill)
  threads.map(&:join)

  [resp, @resp_errors]
end
handle_exceptions() { || ... } click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 328
def handle_exceptions
  begin
    yield
  rescue Error::ServerCertificateRevoked
    raise
  rescue => exc
    Utils.warn_bg_exception(
      "Error performing OCSP verification for '#{host_name}'",
      exc,
      **options)
    false
  end
end
raise_revoked_error(resp) click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 342
def raise_revoked_error(resp)
  if resp.uri == resp.original_uri
    redirect = ''
  else
    redirect = " (redirected from #{resp.original_uri})"
  end
  raise Error::ServerCertificateRevoked, "TLS certificate of '#{host_name}' has been revoked according to '#{resp.uri}'#{redirect} for reason '#{resp.revocation_reason}' at '#{resp.revocation_time}'"
end
report_response_body(body) click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 359
def report_response_body(body)
  if body
    ": #{body}"
  else
    ''
  end
end
report_uri(original_uri, uri) click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 351
def report_uri(original_uri, uri)
  if URI(uri) == URI(original_uri)
    uri
  else
    "#{original_uri} (redirected to #{uri})"
  end
end
return_ocsp_response(resp, errors = nil) click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 307
def return_ocsp_response(resp, errors = nil)
  if resp
    if resp.cert_status == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
      raise_revoked_error(resp)
    end
    true
  else
    reasons = []
    errors.length.times do
      reasons << errors.shift
    end
    if reasons.empty?
      msg = "No responses from responders: #{ocsp_uris.join(', ')} within #{timeout} seconds"
    else
      msg = "For responders #{ocsp_uris.join(', ')} with a timeout of #{timeout} seconds: #{reasons.join(', ')}"
    end
    log_warn("TLS certificate of '#{host_name}' could not be definitively verified via OCSP: #{msg}")
    false
  end
end
verify_one_responder(uri) click to toggle source
# File lib/mongo/socket/ocsp_verifier.rb, line 172
def verify_one_responder(uri)
  original_uri = uri
  redirect_count = 0
  http_response = nil
  loop do
    http_response = begin
      uri = URI(uri)
      Net::HTTP.start(uri.hostname, uri.port) do |http|
        path = uri.path
        if path.empty?
          path = '/'
        end
        http.post(path, @serialized_req,
          'content-type' => 'application/ocsp-request')
      end
    rescue IOError, SystemCallError => e
      @resp_errors << "OCSP request to #{report_uri(original_uri, uri)} failed: #{e.class}: #{e}"
      return false
    end

    code = http_response.code.to_i
    if (300..399).include?(code)
      redirected_uri = http_response.header['location']
      uri = ::URI.join(uri, redirected_uri)
      redirect_count += 1
      if redirect_count > 5
        @resp_errors << "OCSP request to #{report_uri(original_uri, uri)} failed: too many redirects (6)"
        return false
      end
      next
    end

    if code >= 400
      @resp_errors << "OCSP request to #{report_uri(original_uri, uri)} failed with HTTP status code #{http_response.code}" + report_response_body(http_response.body)
      return false
    end

    if code != 200
      # There must be a body provided with the response, if one isn't
      # provided the response cannot be verified.
      @resp_errors << "OCSP request to #{report_uri(original_uri, uri)} failed with unexpected HTTP status code #{http_response.code}" + report_response_body(http_response.body)
      return false
    end

    break
  end

  resp = OpenSSL::OCSP::Response.new(http_response.body).basic
  unless resp.verify([ca_cert], cert_store)
    # Ruby's OpenSSL binding discards error information - see
    # https://github.com/ruby/openssl/issues/395
    @resp_errors << "OCSP response from #{report_uri(original_uri, uri)} failed signature verification; set `OpenSSL.debug = true` to see why"
    return false
  end

  if @req.check_nonce(resp) == 0
    @resp_errors << "OCSP response from #{report_uri(original_uri, uri)} included invalid nonce"
    return false
  end

  if resp.respond_to?(:find_response)
    # Ruby 2.4+
    resp = resp.find_response(cert_id)
    # TODO make a new class instead of patching the stdlib one?
    resp.instance_variable_set('@uri', uri)
    resp.instance_variable_set('@original_uri', original_uri)
    class << resp
      attr_reader :uri, :original_uri
    end
  else
    # Ruby 2.3
    found = nil
    resp.status.each do |_cert_id, cert_status, revocation_reason, revocation_time, this_update, next_update, extensions|
      if _cert_id.cmp(cert_id)
        found = OpenStruct.new(
          cert_status: cert_status,
          certid: _cert_id,
          next_update: next_update,
          this_update: this_update,
          revocation_reason: revocation_reason,
          revocation_time: revocation_time,
          extensions: extensions,
          uri: uri,
          original_uri: original_uri,
        )
        class << found
          # Unlike the stdlib method, this one doesn't accept
          # any arguments.
          def check_validity
            now = Time.now
            this_update <= now && next_update >= now
          end
        end
        break
      end
    end
    resp = found
  end

  unless resp
    @resp_errors << "OCSP response from #{report_uri(original_uri, uri)} did not include information about the requested certificate"
    return false
  end

  unless resp.check_validity
    @resp_errors << "OCSP response from #{report_uri(original_uri, uri)} was invalid: this_update was in the future or next_update time has passed"
    return false
  end

  unless [
    OpenSSL::OCSP::V_CERTSTATUS_GOOD,
    OpenSSL::OCSP::V_CERTSTATUS_REVOKED,
  ].include?(resp.cert_status)
    @resp_errors << "OCSP response from #{report_uri(original_uri, uri)} had a non-definitive status: #{resp.cert_status}"
    return false
  end

  # Note this returns the redirected URI
  @resp_queue << resp
rescue => exc
  Utils.warn_bg_exception("Error performing OCSP verification for '#{host_name}' via '#{uri}'", exc,
    logger: options[:logger],
    log_prefix: options[:log_prefix],
    bg_error_backtrace: options[:bg_error_backtrace],
  )
  false
ensure
  @outstanding_requests_lock.synchronize do
    @outstanding_requests -= 1
    if @outstanding_requests == 0
      @resp_queue << nil
    end
  end
end