#--
# Author:: Daniel DeLeo (<dan@chef.io>)
# Copyright:: Copyright (c) Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "chef/mixin/powershell_exec"
require_relative "auth_credentials"
require_relative "../exceptions"
require_relative "../win32/registry"
autoload :OpenSSL, "openssl"

class Chef
  class HTTP
    class Authenticator
      DEFAULT_SERVER_API_VERSION = "2".freeze

      extend Chef::Mixin::PowershellExec

      attr_reader :signing_key_filename
      attr_reader :raw_key
      attr_reader :attr_names
      attr_reader :auth_credentials
      attr_reader :version_class
      attr_reader :api_version

      attr_accessor :sign_request

      def initialize(opts = {})
        @raw_key = nil
        @sign_request = true
        @signing_key_filename = opts[:signing_key_filename]
        @key = load_signing_key(opts[:signing_key_filename], opts[:raw_key])
        @auth_credentials = AuthCredentials.new(opts[:client_name], @key, use_ssh_agent: opts[:ssh_agent_signing])
        @version_class = opts[:version_class]
        @api_version = opts[:api_version]
      end

      def handle_request(method, url, headers = {}, data = false)
        headers["X-Ops-Server-API-Version"] = request_version
        headers.merge!(authentication_headers(method, url, data, headers)) if sign_requests?
        [method, url, headers, data]
      end

      def handle_response(http_response, rest_request, return_value)
        [http_response, rest_request, return_value]
      end

      def stream_response_handler(response)
        nil
      end

      def handle_stream_complete(http_response, rest_request, return_value)
        [http_response, rest_request, return_value]
      end

      def request_version
        if version_class
          version_class.best_request_version
        elsif api_version
          api_version
        elsif Chef::ServerAPIVersions.instance.negotiated?
          Chef::ServerAPIVersions.instance.max_server_version.to_s
        else
          DEFAULT_SERVER_API_VERSION
        end
      end

      def sign_requests?
        auth_credentials.sign_requests? && @sign_request
      end

      def client_name
        @auth_credentials.client_name
      end

      def detect_certificate_key(client_name)
        self.class.detect_certificate_key(client_name)
      end

      def check_certstore_for_key(client_name)
        self.class.check_certstore_for_key(client_name)
      end

      def retrieve_certificate_key(client_name)
        self.class.retrieve_certificate_key(client_name)
      end

      def get_cert_password
        self.class.get_cert_password
      end

      def encrypt_pfx_pass_with_vector
        self.class.encrypt_pfx_pass_with_vector
      end

      def decrypt_pfx_pass_with_vector
        self.class.decrypt_pfx_pass_with_vector
      end

      # Detects if a private key exists in a certificate repository like Keychain (macOS) or Certificate Store (Windows)
      #
      # @param client_name - we're using the node name to store and retrieve any keys
      # Returns true if a key is found, false if not. False will trigger a registration event which will lead to a certificate based key being created
      #
      def self.detect_certificate_key(client_name)
        if ChefUtils.windows?
          check_certstore_for_key(client_name)
        else # generic return for Mac and LInux clients
          false
        end
      end

      def self.get_cert_user
        Chef::Config[:auth_key_registry_type] == "user" ? "CurrentUser" : "LocalMachine"
      end

      def self.get_registry_user
        Chef::Config[:auth_key_registry_type] == "user" ? "HKEY_CURRENT_USER" : "HKEY_LOCAL_MACHINE"
      end

      def self.check_certstore_for_key(client_name)
        store = get_cert_user
        powershell_code = <<~CODE
          $cert = Get-ChildItem -path cert:\\#{store}\\My -Recurse -Force  | Where-Object { $_.Subject -Match "chef-#{client_name}" } -ErrorAction Stop
          if (($cert.HasPrivateKey -eq $true) -and ($cert.PrivateKey.Key.ExportPolicy -ne "NonExportable")) {
            return $true
          }
          else{
            return $false
          }
        CODE
        powershell_exec!(powershell_code).result
      end

      def load_signing_key(key_file, raw_key = nil)
        results = retrieve_certificate_key(Chef::Config[:node_name])

        if !!results
          @raw_key = results
        elsif key_file == nil? && raw_key == nil?
          puts "\nNo key detected\n"
        elsif !!key_file
          @raw_key = IO.read(key_file).strip
        elsif !!raw_key
          @raw_key = raw_key.strip
        else
          return
        end
        # Pass in '' as the passphrase to avoid OpenSSL prompting on the TTY if
        # given an encrypted key. This also helps if using a single file for
        # both the public and private key with ssh-agent mode.
        @key = OpenSSL::PKey::RSA.new(@raw_key, "")
      rescue SystemCallError, IOError => e
        Chef::Log.warn "Failed to read the private key #{key_file}: #{e.inspect}"
        raise Chef::Exceptions::PrivateKeyMissing, "I cannot read #{key_file}, which you told me to use to sign requests!"
      rescue OpenSSL::PKey::RSAError
        msg = "The file #{key_file} or :raw_key option does not contain a correctly formatted private key or the key is encrypted.\n"
        msg << "The key file should begin with '-----BEGIN RSA PRIVATE KEY-----' and end with '-----END RSA PRIVATE KEY-----'"
        raise Chef::Exceptions::InvalidPrivateKey, msg
      end

      def self.get_cert_password
        store = get_registry_user
        @win32registry = Chef::Win32::Registry.new
        path = "#{store}\\Software\\Progress\\Authentication"
        # does the registry key even exist?
        # password_blob should be an array of hashes
        password_blob = @win32registry.get_values(path)
        if password_blob.nil? || password_blob.empty?
          raise Chef::Exceptions::Win32RegKeyMissing
        end

        # Did someone have just the password stored in the registry?
        raw_data = password_blob.map { |x| x[:data] }
        vector = raw_data[2]
        if !!vector
          decrypted_password = decrypt_pfx_pass_with_vector(password_blob)
        else
          decrypted_password = decrypt_pfx_pass_with_password(password_blob)
          if !!decrypted_password
            migrate_pass_to_use_vector(decrypted_password)
          else
            Chef::Log.error("Failed to retrieve certificate password")
          end
        end
        decrypted_password
      rescue Chef::Exceptions::Win32RegKeyMissing
        # if we don't have a password, log that and generate one
        store = get_registry_user
        new_path = "#{store}\\Software\\Progress\\Authentication"
        unless @win32registry.key_exists?(new_path)
          @win32registry.create_key(new_path, true)
        end
        create_and_store_new_password
      end

      def self.encrypt_pfx_pass_with_vector(password)
        powershell_code = <<~CODE
          $AES = [System.Security.Cryptography.Aes]::Create()
          $key_temp = [System.Convert]::ToBase64String($AES.Key)
          $iv_temp = [System.Convert]::ToBase64String($AES.IV)
          $encryptor = $AES.CreateEncryptor()
          [System.Byte[]]$Bytes =  [System.Text.Encoding]::Unicode.GetBytes("#{password}")
          $EncryptedBytes = $encryptor.TransformFinalBlock($Bytes,0,$Bytes.Length)
          $EncryptedBase64String = [System.Convert]::ToBase64String($EncryptedBytes)
          # create array of encrypted pass, key, iv
          $password_blob = @($EncryptedBase64String, $key_temp, $iv_temp)
          return $password_blob
        CODE
        powershell_exec!(powershell_code).result
      end

      def self.decrypt_pfx_pass_with_vector(password_blob)
        raw_data = password_blob.map { |x| x[:data] }
        password = raw_data[0]
        key = raw_data[1]
        vector = raw_data[2]

        powershell_code = <<~CODE
          $KeyBytes = [System.Convert]::FromBase64String("#{key}")
          $IVBytes = [System.Convert]::FromBase64String("#{vector}")
          $aes = [System.Security.Cryptography.Aes]::Create()
          $aes.Key = $KeyBytes
          $aes.IV = $IVBytes
          $EncryptedBytes = [System.Convert]::FromBase64String("#{password}")
          $Decryptor = $aes.CreateDecryptor()
          $DecryptedBytes = $Decryptor.TransformFinalBlock($EncryptedBytes,0,$EncryptedBytes.Length)
          $DecryptedString = [System.Text.Encoding]::Unicode.GetString($DecryptedBytes)
          return $DecryptedString
        CODE
        results = powershell_exec!(powershell_code).result
      end

      def self.decrypt_pfx_pass_with_password(password_blob)
        password = ""
        password_blob.each do |secret|
          if secret[:name] == "PfxPass"
            password = secret[:data]
          else
            Chef::Log.error("Failed to retrieve a password for the private key")
          end
        end
        powershell_code = <<~CODE
          $secure_string = "#{password}" | ConvertTo-SecureString
          $string = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((($secure_string))))
          return $string
        CODE
        powershell_exec!(powershell_code).result
      end

      def self.migrate_pass_to_use_vector(password)
        store = get_cert_user
        corrected_store = (store == "CurrentUser" ? "HKCU" : "HKLM")
        powershell_code = <<~CODE
          Remove-ItemProperty -Path "#{corrected_store}:\\Software\\Progress\\Authentication" -Name "PfXPass"
        CODE
        powershell_exec!(powershell_code)
        create_and_store_new_password(password)
      end

      # This method name is a bit of a misnomer. We call it to legit create a new password using the vector format.
      # But we also call it with a password that needs to be migrated to use the vector format too.
      def self.create_and_store_new_password(password = nil)
        @win32registry = Chef::Win32::Registry.new
        store = get_registry_user
        path = "#{store}\\Software\\Progress\\Authentication"
        if password.nil?
          require "securerandom" unless defined?(SecureRandom)
          size = 14
          password = SecureRandom.alphanumeric(size)
        end
        encrypted_blob = encrypt_pfx_pass_with_vector(password)
        encrypted_password = encrypted_blob[0]
        key = encrypted_blob[1]
        vector = encrypted_blob[2]
        values = [
                  { name: "PfxPass", type: :string, data: encrypted_password },
                  { name: "PfxKey", type: :string, data: key },
                  { name: "PfxIV", type: :string, data: vector },
                ]
        values.each do |i|
          @win32registry.set_value(path, i)
        end
        password
      end

      def self.retrieve_certificate_key(client_name)
        if ChefUtils.windows?
          require "openssl" unless defined?(OpenSSL)
          password = get_cert_password
          return false unless password

          if !!check_certstore_for_key(client_name)
            ps_blob = powershell_exec!(get_the_key_ps(client_name, password)).result
            file_path = ps_blob["PSPath"].split("::")[1]
            pkcs = OpenSSL::PKCS12.new(File.binread(file_path), password)

            # We check the pfx we just extracted the private key from
            # if that cert is expiring in 7 days or less we generate a new pfx/p12 object
            # then we post the new public key from that to the client endpoint on
            # chef server.
            File.delete(file_path)
            key_expiring = is_certificate_expiring?(pkcs)
            if key_expiring
              powershell_exec!(delete_old_key_ps(client_name))
              ::Chef::Client.update_key_and_register(Chef::Config[:client_name], pkcs)
            end

            return pkcs.key.private_to_pem
          end
        end

        false
      end

      def self.is_certificate_expiring?(pkcs)
        today = Date.parse(Time.now.utc.iso8601)
        future = Date.parse(pkcs.certificate.not_after.iso8601)
        future.mjd - today.mjd <= 7
      end

      def self.get_the_key_ps(client_name, password)
        store = get_cert_user
        powershell_code = <<~CODE
            Try {
              $my_pwd = ConvertTo-SecureString -String "#{password}" -Force -AsPlainText;
              $cert = Get-ChildItem -path cert:\\#{store}\\My -Recurse | Where-Object { $_.Subject -match "chef-#{client_name}$" } -ErrorAction Stop;
              $tempfile = [System.IO.Path]::GetTempPath() + "export_pfx.pfx";
              Export-PfxCertificate -Cert $cert -Password $my_pwd -FilePath $tempfile;
            }
            Catch {
              return $false
            }
        CODE
      end

      def self.delete_old_key_ps(client_name)
        store = get_cert_user
        powershell_code = <<~CODE
          Get-ChildItem -path cert:\\#{store}\\My -Recurse | Where-Object { $_.Subject -match "chef-#{client_name}$" } | Remove-Item -ErrorAction Stop;
        CODE
      end

      def authentication_headers(method, url, json_body = nil, headers = nil)
        request_params = {
          http_method: method,
          path: url.path,
          body: json_body,
          host: "#{url.host}:#{url.port}",
          headers: headers,
        }
        request_params[:body] ||= ""
        auth_credentials.signature_headers(request_params)
      end
    end
  end
end
