# frozen_string_literal: true

require_relative '../../puppet/ssl'

# Verify an SSL connection.
#
# @api private
class Puppet::SSL::Verifier
  FIVE_MINUTES_AS_SECONDS = 5 * 60

  attr_reader :ssl_context

  # Create a verifier using an `ssl_context`.
  #
  # @param hostname [String] FQDN of the server we're attempting to connect to
  # @param ssl_context [Puppet::SSL::SSLContext] ssl_context containing CA certs,
  #   CRLs, etc needed to verify the server's certificate chain
  # @api private
  def initialize(hostname, ssl_context)
    @hostname = hostname
    @ssl_context = ssl_context
  end

  # Return true if `self` is reusable with `verifier` meaning they
  # are using the same `ssl_context`, so there's no loss of security
  # when using a cached connection.
  #
  # @param verifier [Puppet::SSL::Verifier] the verifier to compare against
  # @return [Boolean] return true if a cached connection can be used, false otherwise
  # @api private
  def reusable?(verifier)
    verifier.instance_of?(self.class) &&
      verifier.ssl_context.equal?(@ssl_context) # same object?
  end

  # Configure the `http` connection based on the current `ssl_context`.
  #
  # @param http [Net::HTTP] connection
  # @api private
  def setup_connection(http)
    http.cert_store = @ssl_context[:store]
    http.cert = @ssl_context[:client_cert]
    http.key = @ssl_context[:private_key]
    # default to VERIFY_PEER
    http.verify_mode = if !@ssl_context[:verify_peer]
                         OpenSSL::SSL::VERIFY_NONE
                       else
                         OpenSSL::SSL::VERIFY_PEER
                       end
    http.verify_callback = self
  end

  # This method is called if `Net::HTTP#start` raises an exception, which
  # could be a result of an openssl error during cert verification, due
  # to ruby's `Socket#post_connection_check`, or general SSL connection
  # error.
  #
  # @param http [Net::HTTP] connection
  # @param error [OpenSSL::SSL::SSLError] connection error
  # @raise [Puppet::SSL::CertVerifyError] SSL connection failed due to a
  #   verification error with the server's certificate or chain
  # @raise [Puppet::Error] server hostname does not match certificate
  # @raise [OpenSSL::SSL::SSLError] low-level SSL connection failure
  # @api private
  def handle_connection_error(http, error)
    raise @last_error if @last_error

    # ruby can pass SSL validation but fail post_connection_check
    peer_cert = http.peer_cert
    if peer_cert && !OpenSSL::SSL.verify_certificate_identity(peer_cert, @hostname)
      raise Puppet::SSL::CertMismatchError.new(peer_cert, @hostname)
    else
      raise error
    end
  end

  # OpenSSL will call this method with the verification result for each cert in
  # the server's chain, working from the root CA to the server's cert. If
  # preverify_ok is `true`, then that cert passed verification. If it's `false`
  # then the current verification error is contained in `store_context.error`.
  # and the current cert is in `store_context.current_cert`.
  #
  # If this method returns `false`, then verification stops and ruby will raise
  # an `OpenSSL::SSL::Error` with "certificate verification failed". If this
  # method returns `true`, then verification continues.
  #
  # If this method ignores a verification error, such as the cert's CRL will be
  # valid within the next 5 minutes, then this method may be called with a
  # different verification error for the same cert.
  #
  # WARNING: If `store_context.error` returns `OpenSSL::X509::V_OK`, don't
  # assume verification passed. Ruby 2.4+ implements certificate hostname
  # checking by default, and if the cert doesn't match the hostname, then the
  # error will be V_OK. Always use `preverify_ok` to determine if verification
  # succeeded or not.
  #
  # @param preverify_ok [Boolean] if `true` the current certificate in `store_context`
  #   was verified. Otherwise, check for the current error in `store_context.error`
  # @param store_context [OpenSSL::X509::StoreContext] The context holding the
  #   verification result for one certificate
  # @return [Boolean] If `true`, continue verifying the chain, even if that means
  #   ignoring the current verification error. If `false`, abort the connection.
  #
  # @api private
  def call(preverify_ok, store_context)
    return true if preverify_ok

    peer_cert = store_context.current_cert

    case store_context.error
    when OpenSSL::X509::V_OK
      # chain is from leaf to root, opposite of the order that `call` is invoked
      chain_cert = store_context.chain.first

      # ruby 2.4 doesn't compare certs based on value, so force to DER byte array
      if peer_cert && chain_cert && peer_cert.to_der == chain_cert.to_der && !OpenSSL::SSL.verify_certificate_identity(peer_cert, @hostname)
        @last_error = Puppet::SSL::CertMismatchError.new(peer_cert, @hostname)
        return false
      end

    # ruby-openssl#74ef8c0cc56b840b772240f2ee2b0fc0aafa2743 now sets the
    # store_context error when the cert is mismatched
    when OpenSSL::X509::V_ERR_HOSTNAME_MISMATCH
      @last_error = Puppet::SSL::CertMismatchError.new(peer_cert, @hostname)
      return false

    when OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
      crl = store_context.current_crl
      if crl && crl.last_update && crl.last_update < Time.now + FIVE_MINUTES_AS_SECONDS
        Puppet.debug("Ignoring CRL not yet valid, current time #{Time.now.utc}, CRL last updated #{crl.last_update.utc}")
        return true
      end
    end

    # TRANSLATORS: `error` is an untranslated message from openssl describing why a certificate in the server's chain is invalid, and `subject` is the identity/name of the failed certificate
    @last_error = Puppet::SSL::CertVerifyError.new(
      _("certificate verify failed [%{error} for %{subject}]") %
      { error: store_context.error_string, subject: peer_cert.subject.to_utf8 },
      store_context.error, peer_cert
    )
    false
  end
end
