#
# Author:: Seth Falcon (<seth@opscode.com>)
# Copyright:: Copyright 2010-2011 Opscode, 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 'yaml'
require 'chef/json_compat'
require 'openssl'
require 'base64'
require 'digest/sha2'
require 'chef/encrypted_data_bag_item'
require 'chef/encrypted_data_bag_item/unsupported_encrypted_data_bag_item_format'
require 'chef/encrypted_data_bag_item/decryption_failure'
require 'chef/encrypted_data_bag_item/assertions'

class Chef::EncryptedDataBagItem

  #=== Decryptor
  # For backwards compatibility, Chef implements decryption/deserialization for
  # older encrypted data bag item formats in addition to the current version.
  # Each decryption/deserialization strategy is implemented as a class in this
  # namespace. For convenience the factory method +Decryptor.for()+ can be used
  # to create an instance of the appropriate strategy for the given encrypted
  # data bag value.
  module Decryptor
    extend Chef::EncryptedDataBagItem::Assertions

    # Detects the encrypted data bag item format version and instantiates a
    # decryptor object for that version. Call #for_decrypted_item on the
    # resulting object to decrypt and deserialize it.
    def self.for(encrypted_value, key)
      format_version = format_version_of(encrypted_value)
      assert_format_version_acceptable!(format_version)
      case format_version
      when 3
        Version3Decryptor.new(encrypted_value, key)
      when 2
        Version2Decryptor.new(encrypted_value, key)
      when 1
        Version1Decryptor.new(encrypted_value, key)
      when 0
        Version0Decryptor.new(encrypted_value, key)
      else
        raise UnsupportedEncryptedDataBagItemFormat,
          "This version of chef does not support encrypted data bag item format version '#{format_version}'"
      end
    end

    def self.format_version_of(encrypted_value)
      if encrypted_value.respond_to?(:key?)
        encrypted_value["version"]
      else
        0
      end
    end

    class Version0Decryptor
      include Chef::EncryptedDataBagItem::Assertions

      attr_reader :encrypted_data
      attr_reader :key

      def initialize(encrypted_data, key)
        @encrypted_data = encrypted_data
        @key = key
      end

      # Returns the used decryption algorithm
      def algorithm
        ALGORITHM
      end

      def for_decrypted_item
        YAML.load(decrypted_data)
      end

      def decrypted_data
        @decrypted_data ||= begin
          plaintext = openssl_decryptor.update(encrypted_bytes)
          plaintext << openssl_decryptor.final
        rescue OpenSSL::Cipher::CipherError => e
          raise DecryptionFailure, "Error decrypting data bag value: '#{e.message}'. Most likely the provided key is incorrect"
        end
      end

      def encrypted_bytes
        Base64.decode64(@encrypted_data)
      end

      def openssl_decryptor
        @openssl_decryptor ||= begin
          d = OpenSSL::Cipher.new(algorithm)
          d.decrypt
          d.pkcs5_keyivgen(key)
          d
        end
      end
    end

    class Version1Decryptor < Version0Decryptor

      attr_reader :encrypted_data
      attr_reader :key

      def initialize(encrypted_data, key)
        @encrypted_data = encrypted_data
        @key = key
      end

      def for_decrypted_item
        Chef::JSONCompat.parse(decrypted_data)["json_wrapper"]
      rescue Chef::Exceptions::JSON::ParseError
        # convert to a DecryptionFailure error because the most likely scenario
        # here is that the decryption step was unsuccessful but returned bad
        # data rather than raising an error.
        raise DecryptionFailure, "Error decrypting data bag value. Most likely the provided key is incorrect"
      end

      def encrypted_bytes
        Base64.decode64(@encrypted_data["encrypted_data"])
      end

      def iv
        Base64.decode64(@encrypted_data["iv"])
      end

      def decrypted_data
        @decrypted_data ||= begin
          plaintext = openssl_decryptor.update(encrypted_bytes)
          plaintext << openssl_decryptor.final
        rescue OpenSSL::Cipher::CipherError => e
          raise DecryptionFailure, "Error decrypting data bag value: '#{e.message}'. Most likely the provided key is incorrect"
        end
      end

      def openssl_decryptor
        @openssl_decryptor ||= begin
          assert_valid_cipher!(@encrypted_data["cipher"], algorithm)
          d = OpenSSL::Cipher.new(algorithm)
          d.decrypt
          # We must set key before iv: https://bugs.ruby-lang.org/issues/8221
          d.key = OpenSSL::Digest::SHA256.digest(key)
          d.iv = iv
          d
        end
      end

    end

    class Version2Decryptor < Version1Decryptor

      def decrypted_data
        validate_hmac! unless @decrypted_data
        super
      end

      def validate_hmac!
        digest = OpenSSL::Digest.new("sha256")
        raw_hmac = OpenSSL::HMAC.digest(digest, key, @encrypted_data["encrypted_data"])

        if candidate_hmac_matches?(raw_hmac)
          true
        else
          raise DecryptionFailure, "Error decrypting data bag value: invalid hmac. Most likely the provided key is incorrect"
        end
      end

      private

      def candidate_hmac_matches?(expected_hmac)
        return false unless @encrypted_data["hmac"]
        expected_bytes = expected_hmac.bytes.to_a
        candidate_hmac_bytes = Base64.decode64(@encrypted_data["hmac"]).bytes.to_a
        valid = expected_bytes.size ^ candidate_hmac_bytes.size
        expected_bytes.zip(candidate_hmac_bytes) { |x, y| valid |= x ^ y.to_i }
        valid == 0
      end
    end

    class Version3Decryptor < Version1Decryptor

      def initialize(encrypted_data, key)
        super
        assert_aead_requirements_met!(algorithm)
      end

      # Returns the used decryption algorithm
      def algorithm
        AEAD_ALGORITHM
      end

      def auth_tag
        auth_tag_b64 = @encrypted_data["auth_tag"]
        if auth_tag_b64.nil?
          raise DecryptionFailure, "Error decrypting data bag value: invalid authentication tag. Most likely the data is corrupted"
        end
        Base64.decode64(auth_tag_b64)
      end

      def openssl_decryptor
        @openssl_decryptor ||= begin
          d = super
          d.auth_tag = auth_tag
          d.auth_data = ''
          d
        end
      end

    end

  end
end
