#
# Author:: Seth Falcon (<seth@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.
#

autoload :Base64, "base64"
require "digest/sha2" unless defined?(Digest::SHA2)
autoload :OpenSSL, "openssl"
autoload :FFI_Yajl, "ffi_yajl"
require_relative "../encrypted_data_bag_item"
require_relative "unsupported_encrypted_data_bag_item_format"
require_relative "encryption_failure"
require_relative "assertions"

class Chef::EncryptedDataBagItem

  # Implementation class for converting plaintext data bag item values to an
  # encrypted value, including any necessary wrappers and metadata.
  module Encryptor

    # "factory" method that creates an encryptor object with the proper class
    # for the desired encrypted data bag format version.
    #
    # +Chef::Config[:data_bag_encrypt_version]+ determines which version is used.
    def self.new(value, secret, iv = nil)
      format_version = Chef::Config[:data_bag_encrypt_version]
      case format_version
      when 1
        Version1Encryptor.new(value, secret, iv)
      when 2
        Version2Encryptor.new(value, secret, iv)
      when 3
        Version3Encryptor.new(value, secret, iv)
      else
        raise UnsupportedEncryptedDataBagItemFormat,
          "Invalid encrypted data bag format version `#{format_version}'. Supported versions are '1', '2', '3'"
      end
    end

    class Version1Encryptor
      attr_reader :key
      attr_reader :plaintext_data

      include Chef::EncryptedDataBagItem::Assertions

      # Create a new Encryptor for +data+, which will be encrypted with the given
      # +key+.
      #
      # === Arguments:
      # * data: An object of any type that can be serialized to json
      # * key: A String representing the desired passphrase
      # * iv: The optional +iv+ parameter is intended for testing use only. When
      # *not* supplied, Encryptor will use OpenSSL to generate a secure random
      # IV, which is what you want.
      def initialize(plaintext_data, key, iv = nil)
        @plaintext_data = plaintext_data
        @key = key
        @iv = iv && Base64.decode64(iv)
      end

      # Returns the used encryption algorithm
      def algorithm
        ALGORITHM
      end

      # Returns a wrapped and encrypted version of +plaintext_data+ suitable for
      # using as the value in an encrypted data bag item.
      def for_encrypted_item
        {
          "encrypted_data" => encrypted_data,
          "iv" => Base64.encode64(iv),
          "version" => 1,
          "cipher" => algorithm,
        }
      end

      # Generates or returns the IV.
      def iv
        # Generated IV comes from OpenSSL::Cipher#random_iv
        # This gets generated when +openssl_encryptor+ gets created.
        openssl_encryptor if @iv.nil?
        @iv
      end

      # Generates (and memoizes) an OpenSSL::Cipher object and configures
      # it for the specified iv and encryption key.
      def openssl_encryptor
        @openssl_encryptor ||= begin
          encryptor = OpenSSL::Cipher.new(algorithm)
          encryptor.encrypt
          # We must set key before iv: https://bugs.ruby-lang.org/issues/8221
          encryptor.key = OpenSSL::Digest.digest("SHA256", key)
          @iv ||= encryptor.random_iv
          encryptor.iv = @iv
          encryptor
        end
      end

      # Encrypts and Base64 encodes +serialized_data+
      def encrypted_data
        @encrypted_data ||= begin
          enc_data = openssl_encryptor.update(serialized_data)
          enc_data << openssl_encryptor.final
          Base64.encode64(enc_data)
        end
      end

      # Wraps the data in a single key Hash (JSON Object) and converts to JSON.
      # The wrapper is required because we accept values (such as Integers or
      # Strings) that do not produce valid JSON when serialized without the
      # wrapper.
      def serialized_data
        FFI_Yajl::Encoder.encode(json_wrapper: plaintext_data)
      end

      def self.encryptor_keys
        %w{ encrypted_data iv version cipher }
      end
    end

    class Version2Encryptor < Version1Encryptor

      # Returns a wrapped and encrypted version of +plaintext_data+ suitable for
      # using as the value in an encrypted data bag item.
      def for_encrypted_item
        {
          "encrypted_data" => encrypted_data,
          "hmac" => hmac,
          "iv" => Base64.encode64(iv),
          "version" => 2,
          "cipher" => algorithm,
        }
      end

      # Generates an HMAC-SHA2-256 of the encrypted data (encrypt-then-mac)
      def hmac
        @hmac ||= begin
          digest = OpenSSL::Digest.new("sha256")
          raw_hmac = OpenSSL::HMAC.digest(digest, key, encrypted_data)
          Base64.encode64(raw_hmac)
        end
      end

      def self.encryptor_keys
        super + %w{ hmac }
      end
    end

    class Version3Encryptor < Version1Encryptor
      include Chef::EncryptedDataBagItem::Assertions

      def initialize(plaintext_data, key, iv = nil)
        super
        assert_aead_requirements_met!(algorithm)
        @auth_tag = nil
      end

      # Returns a wrapped and encrypted version of +plaintext_data+ suitable for
      # using as the value in an encrypted data bag item.
      def for_encrypted_item
        {
          "encrypted_data" => encrypted_data,
          "iv" => Base64.encode64(iv),
          "auth_tag" => Base64.encode64(auth_tag),
          "version" => 3,
          "cipher" => algorithm,
        }
      end

      # Returns the used encryption algorithm
      def algorithm
        AEAD_ALGORITHM
      end

      # Returns a wrapped and encrypted version of +plaintext_data+ suitable for
      # Returns the auth_tag.
      def auth_tag
        # Generated auth_tag comes from OpenSSL::Cipher#auth_tag
        # This must be generated after the data is encrypted
        if @auth_tag.nil?
          raise EncryptionFailure, "Internal Error: GCM authentication tag read before encryption"
        end

        @auth_tag
      end

      # Generates (and memoizes) an OpenSSL::Cipher object and configures
      # it for the specified iv and encryption key using AEAD
      def openssl_encryptor
        @openssl_encryptor ||= begin
          encryptor = super
          encryptor.auth_data = ""
          encryptor
        end
      end

      # Encrypts, Base64 encodes +serialized_data+ and gets the authentication tag
      def encrypted_data
        @encrypted_data ||= begin
          enc_data_b64 = super
          @auth_tag = openssl_encryptor.auth_tag
          enc_data_b64
        end
      end

      def self.encryptor_keys
        super + %w{ auth_tag }
      end

    end

  end
end
