require 'puppet/ssl/openssl_loader'
require 'puppet/ssl'

# Perform peer certificate verification against the known CA.
# If there is no CA information known, then no verification is performed
#
# @deprecated
# @api private
#
class Puppet::SSL::Validator::DefaultValidator #< class Puppet::SSL::Validator
  attr_reader :peer_certs
  attr_reader :verify_errors
  attr_reader :last_error

  FIVE_MINUTES_AS_SECONDS = 5 * 60

  # Creates a new DefaultValidator, optionally with an SSL Configuration and SSL Host.
  #
  # @param ca_path [String] Filepath for the cacert
  #
  # @api private
  #
  def initialize(
    ca_path = Puppet[:ssl_client_ca_auth] || Puppet[:localcacert])

    reset!
    @ca_path = ca_path
  end


  # Resets this validator to its initial validation state. The ssl configuration is not changed.
  #
  # @api private
  #
  def reset!
    @peer_certs = []
    @verify_errors = []
    @hostname = nil
    @last_error = nil
  end

  # Performs verification of the SSL connection and collection of the
  # certificates for use in constructing the error message if the verification
  # failed.  This callback will be executed once for each certificate in a
  # chain being verified.
  #
  # From the [OpenSSL
  # documentation](https://www.openssl.org/docs/ssl/SSL_CTX_set_verify.html):
  # The `verify_callback` function is used to control the behaviour when the
  # SSL_VERIFY_PEER flag is set. It must be supplied by the application and
  # receives two arguments: preverify_ok indicates, whether the verification of
  # the certificate in question was passed (preverify_ok=1) or not
  # (preverify_ok=0). x509_store_ctx is a pointer to the complete context used for
  # the certificate chain verification.
  #
  # See {Puppet::Network::HTTP::Connection} for more information and where this
  # class is intended to be used.
  #
  # @param [Boolean] preverify_ok indicates whether the verification of the
  #   certificate in question was passed (preverify_ok=true)
  # @param [OpenSSL::X509::StoreContext] store_context holds the X509 store context
  #   for the chain being verified.
  #
  # @return [Boolean] false if the peer is invalid, true otherwise.
  #
  # @api private
  #
  def call(preverify_ok, store_context)
    current_cert = store_context.current_cert
    @peer_certs << current_cert

    # We must make a copy since the scope of the store_context will be lost
    # across invocations of this method.
    if preverify_ok
      # If we've copied all of the certs in the chain out of the SSL library
      if @peer_certs.length == store_context.chain.length
        # (#20027) The peer cert must be issued by a specific authority
        preverify_ok = valid_peer?
      end
    else
      error = store_context.error || 0
      error_string = store_context.error_string || "OpenSSL error #{error}"

      case error
      when OpenSSL::X509::V_OK
        if @hostname
          # 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 current_cert && chain_cert && current_cert.to_der == chain_cert.to_der && !OpenSSL::SSL.verify_certificate_identity(current_cert, @hostname)
            @last_error = Puppet::SSL::CertMismatchError.new(current_cert, @hostname)
            return false
          else
            @verify_errors << "#{error_string} for #{current_cert.subject.to_utf8}"
          end
        else
          @verify_errors << "#{error_string} for #{current_cert.subject.to_utf8}"
        end

      when OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
        # current_crl can be nil
        # https://github.com/ruby/ruby/blob/ruby_1_9_3/ext/openssl/ossl_x509store.c#L501-L510
        crl = store_context.current_crl
        if crl
          if 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}")
            preverify_ok = true
          else
            @verify_errors << "#{error_string} for #{crl.issuer.to_utf8}"
          end
        else
          @verify_errors << error_string
        end
      else
        @verify_errors << "#{error_string} for #{current_cert.subject.to_utf8}"
      end
    end
    preverify_ok
  rescue => ex
    @verify_errors << ex.message
    false
  end

  # Registers the instance's call method with the connection.
  #
  # @param [Net::HTTP] connection The connection to validate
  #
  # @param [Puppet::SSL::Host] host The host object containing SSL data
  # @return [void]
  #
  # @api private
  #
  def setup_connection(connection, ssl_host = Puppet.lookup(:ssl_host))
    @hostname = connection.address

    if ssl_certificates_are_present?
      connection.cert_store = ssl_host.ssl_store
      connection.ca_file = @ca_path
      connection.cert = ssl_host.certificate.content
      connection.key = ssl_host.key.content
      connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
      connection.verify_callback = self
    else
      connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
    end
  end

  ##
  # Decode a string of concatenated certificates
  #
  # @return [Array<OpenSSL::X509::Certificate>]
  def decode_cert_bundle(bundle_str)
    re = /-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m
    pem_ary = bundle_str.scan(re)
    pem_ary.map do |pem_str|
      OpenSSL::X509::Certificate.new(pem_str)
    end
  end

  # read_file makes testing easier.
  def read_file(path)
    # https://www.ietf.org/rfc/rfc2459.txt defines the x509 V3 certificate format
    # CA bundles are concatenated X509 certificates, but may also include
    # comments, which could have UTF-8 characters
    Puppet::FileSystem.read(path, :encoding => Encoding::UTF_8)
  end

  # Validates the peer certificates against the authorized certificates.
  #
  # @api private
  #
  def valid_peer?
    descending_cert_chain = @peer_certs.reverse
    authz_ca_certs = decode_cert_bundle(read_file(@ca_path))

    if not has_authz_peer_cert(descending_cert_chain, authz_ca_certs)
      msg = "The server presented a SSL certificate chain which does not include a " <<
        "CA listed in the ssl_client_ca_auth file.  "
      msg << "Authorized Issuers: #{authz_ca_certs.collect {|c| c.subject.to_utf8}.join(', ')}  " <<
        "Peer Chain: #{descending_cert_chain.collect {|c| c.subject.to_utf8}.join(' => ')}"
      @verify_errors << msg
      false
    else
      true
    end
  end

  # Checks if the set of peer_certs contains at least one certificate issued
  # by a certificate listed in authz_certs
  #
  # @return [Boolean]
  #
  # @api private
  #
  def has_authz_peer_cert(peer_certs, authz_certs)
    peer_certs.any? do |peer_cert|
      authz_certs.any? do |authz_cert|
        peer_cert.verify(authz_cert.public_key)
      end
    end
  end

  # @api private
  #
  def ssl_certificates_are_present?
    Puppet::FileSystem.exist?(Puppet[:hostcert]) && Puppet::FileSystem.exist?(@ca_path)
  end
end
