# frozen_string_literal: true

require 'forwardable'

module Aws
  module S3

    REQUIRED_PARAMS = [:key_wrap_schema, :content_encryption_schema, :security_profile]
    SUPPORTED_SECURITY_PROFILES = [:v2, :v2_and_legacy]

    # Provides an encryption client that encrypts and decrypts data client-side,
    # storing the encrypted data in Amazon S3.  The `EncryptionV2::Client` (V2 Client)
    # provides improved security over the `Encryption::Client` (V1 Client)
    # by using more modern and secure algorithms. You can use the V2 Client
    # to continue decrypting objects encrypted using deprecated algorithms
    # by setting security_profile: :v2_and_legacy. The latest V1 Client also
    # supports reading and decrypting objects encrypted by the V2 Client.
    #
    # This client uses a process called "envelope encryption". Your private
    # encryption keys and your data's plain-text are **never** sent to
    # Amazon S3. **If you lose you encryption keys, you will not be able to
    # decrypt your data.**
    #
    # ## Envelope Encryption Overview
    #
    # The goal of envelope encryption is to combine the performance of
    # fast symmetric encryption while maintaining the secure key management
    # that asymmetric keys provide.
    #
    # A one-time-use symmetric key (envelope key) is generated client-side.
    # This is used to encrypt the data client-side. This key is then
    # encrypted by your master key and stored alongside your data in Amazon
    # S3.
    #
    # When accessing your encrypted data with the encryption client,
    # the encrypted envelope key is retrieved and decrypted client-side
    # with your master key. The envelope key is then used to decrypt the
    # data client-side.
    #
    # One of the benefits of envelope encryption is that if your master key
    # is compromised, you have the option of just re-encrypting the stored
    # envelope symmetric keys, instead of re-encrypting all of the
    # data in your account.
    #
    # ## Basic Usage
    #
    # The encryption client requires an {Aws::S3::Client}. If you do not
    # provide a `:client`, then a client will be constructed for you.
    #
    #     require 'openssl'
    #     key = OpenSSL::PKey::RSA.new(1024)
    #
    #     # encryption client
    #     s3 = Aws::S3::EncryptionV2::Client.new(
    #       encryption_key: key,
    #       key_wrap_schema: :rsa_oaep_sha1, # the key_wrap_schema must be rsa_oaep_sha1 for asymmetric keys
    #       content_encryption_schema: :aes_gcm_no_padding,
    #       security_profile: :v2 # use :v2_and_legacy to allow reading/decrypting objects encrypted by the V1 encryption client
    #     )
    #
    #     # round-trip an object, encrypted/decrypted locally
    #     s3.put_object(bucket:'aws-sdk', key:'secret', body:'handshake')
    #     s3.get_object(bucket:'aws-sdk', key:'secret').body.read
    #     #=> 'handshake'
    #
    #     # reading encrypted object without the encryption client
    #     # results in the getting the cipher text
    #     Aws::S3::Client.new.get_object(bucket:'aws-sdk', key:'secret').body.read
    #     #=> "... cipher text ..."
    #
    # ## Required Configuration
    #
    # You must configure all of the following:
    #
    # * a key or key provider - See the Keys section below. The key provided determines
    #   the key wrapping schema(s) supported for both encryption and decryption.
    # * `key_wrap_schema` - The key wrapping schema. It must match the type of key configured.
    # * `content_encryption_schema` - The only supported value currently is `:aes_gcm_no_padding`.
    #    More options will be added in future releases.
    # * `security_profile` - Determines the support for reading objects written
    #    using older key wrap or content encryption schemas. If you need to read
    #    legacy objects encrypted by an existing V1 Client, then set this to `:v2_and_legacy`.
    #    Otherwise, set it to `:v2`
    #
    # ## Keys
    #
    # For client-side encryption to work, you must provide one of the following:
    #
    # * An encryption key
    # * A {KeyProvider}
    # * A KMS encryption key id
    #
    # Additionally, the key wrapping schema must agree with the type of the key:
    # * :aes_gcm: An AES encryption key or a key provider.
    # * :rsa_oaep_sha1: An RSA encryption key or key provider.
    # * :kms_context: A KMS encryption key id
    #
    # ### An Encryption Key
    #
    # You can pass a single encryption key. This is used as a master key
    # encrypting and decrypting all object keys.
    #
    #     key = OpenSSL::Cipher.new("AES-256-ECB").random_key # symmetric key - used with `key_wrap_schema: :aes_gcm`
    #     key = OpenSSL::PKey::RSA.new(1024) # asymmetric key pair - used with `key_wrap_schema: :rsa_oaep_sha1`
    #
    #     s3 = Aws::S3::EncryptionV2::Client.new(
    #       encryption_key: key,
    #       key_wrap_schema: :aes_gcm, # or :rsa_oaep_sha1 if using RSA
    #       content_encryption_schema: :aes_gcm_no_padding,
    #       security_profile: :v2
    #     )
    #
    # ### Key Provider
    #
    # Alternatively, you can use a {KeyProvider}. A key provider makes
    # it easy to work with multiple keys and simplifies key rotation.
    #
    # ### KMS Encryption Key Id
    #
    # If you pass the id of an AWS Key Management Service (KMS) key and
    # use :kms_content for the key_wrap_schema, then KMS will be used to
    # generate, encrypt and decrypt object keys.
    #
    #     # keep track of the kms key id
    #     kms = Aws::KMS::Client.new
    #     key_id = kms.create_key.key_metadata.key_id
    #
    #     Aws::S3::EncryptionV2::Client.new(
    #       kms_key_id: key_id,
    #       kms_client: kms,
    #       key_wrap_schema: :kms_context,
    #       content_encryption_schema: :aes_gcm_no_padding,
    #       security_profile: :v2
    #     )
    #
    # ## Custom Key Providers
    #
    # A {KeyProvider} is any object that responds to:
    #
    # * `#encryption_materials`
    # * `#key_for(materials_description)`
    #
    # Here is a trivial implementation of an in-memory key provider.
    # This is provided as a demonstration of the key provider interface,
    # and should not be used in production:
    #
    #     class KeyProvider
    #
    #       def initialize(default_key_name, keys)
    #         @keys = keys
    #         @encryption_materials = Aws::S3::EncryptionV2::Materials.new(
    #           key: @keys[default_key_name],
    #           description: JSON.dump(key: default_key_name),
    #         )
    #       end
    #
    #       attr_reader :encryption_materials
    #
    #       def key_for(matdesc)
    #         key_name = JSON.load(matdesc)['key']
    #         if key = @keys[key_name]
    #           key
    #         else
    #           raise "encryption key not found for: #{matdesc.inspect}"
    #         end
    #       end
    #     end
    #
    # Given the above key provider, you can create an encryption client that
    # chooses the key to use based on the materials description stored with
    # the encrypted object. This makes it possible to use multiple keys
    # and simplifies key rotation.
    #
    #     # uses "new-key" for encrypting objects, uses either for decrypting
    #     keys = KeyProvider.new('new-key', {
    #       "old-key" => Base64.decode64("kM5UVbhE/4rtMZJfsadYEdm2vaKFsmV2f5+URSeUCV4="),
    #       "new-key" => Base64.decode64("w1WLio3agRWRTSJK/Ouh8NHoqRQ6fn5WbSXDTHjXMSo="),
    #     }),
    #
    #     # chooses the key based on the materials description stored
    #     # with the encrypted object
    #     s3 = Aws::S3::EncryptionV2::Client.new(
    #       key_provider: keys,
    #       key_wrap_schema: ...,
    #       content_encryption_schema: :aes_gcm_no_padding,
    #       security_profile: :v2
    #     )
    #
    # ## Materials Description
    #
    # A materials description is JSON document string that is stored
    # in the metadata (or instruction file) of an encrypted object.
    # The {DefaultKeyProvider} uses the empty JSON document `"{}"`.
    #
    # When building a key provider, you are free to store whatever
    # information you need to identify the master key that was used
    # to encrypt the object.
    #
    # ## Envelope Location
    #
    # By default, the encryption client store the encryption envelope
    # with the object, as metadata. You can choose to have the envelope
    # stored in a separate "instruction file". An instruction file
    # is an object, with the key of the encrypted object, suffixed with
    # `".instruction"`.
    #
    # Specify the `:envelope_location` option as `:instruction_file` to
    # use an instruction file for storing the envelope.
    #
    #     # default behavior
    #     s3 = Aws::S3::EncryptionV2::Client.new(
    #       key_provider: ...,
    #       envelope_location: :metadata,
    #     )
    #
    #     # store envelope in a separate object
    #     s3 = Aws::S3::EncryptionV2::Client.new(
    #       key_provider: ...,
    #       envelope_location: :instruction_file,
    #       instruction_file_suffix: '.instruction' # default
    #       key_wrap_schema: ...,
    #       content_encryption_schema: :aes_gcm_no_padding,
    #       security_profile: :v2
    #     )
    #
    # When using an instruction file, multiple requests are made when
    # putting and getting the object. **This may cause issues if you are
    # issuing concurrent PUT and GET requests to an encrypted object.**
    #
    module EncryptionV2
      class Client

        extend Deprecations
        extend Forwardable
        def_delegators :@client, :config, :delete_object, :head_object, :build_request

        # Creates a new encryption client. You must configure all of the following:
        #
        # * a key or key provider - The key provided also determines the key wrapping
        #   schema(s) supported for both encryption and decryption.
        # * `key_wrap_schema` - The key wrapping schema. It must match the type of key configured.
        # * `content_encryption_schema` - The only supported value currently is `:aes_gcm_no_padding`
        #    More options will be added in future releases.
        # * `security_profile` - Determines the support for reading objects written
        #    using older key wrap or content encryption schemas. If you need to read
        #    legacy objects encrypted by an existing V1 Client, then set this to `:v2_and_legacy`.
        #    Otherwise, set it to `:v2`
        #
        # To configure the key you must provide one of the following set of options:
        #
        # * `:encryption_key`
        # * `:kms_key_id`
        # * `:key_provider`
        #
        # You may also pass any other options accepted by `Client#initialize`.
        #
        # @option options [S3::Client] :client A basic S3 client that is used
        #   to make api calls. If a `:client` is not provided, a new {S3::Client}
        #   will be constructed.
        #
        # @option options [OpenSSL::PKey::RSA, String] :encryption_key The master
        #   key to use for encrypting/decrypting all objects.
        #
        # @option options [String] :kms_key_id When you provide a `:kms_key_id`,
        #   then AWS Key Management Service (KMS) will be used to manage the
        #   object encryption keys. By default a {KMS::Client} will be
        #   constructed for KMS API calls. Alternatively, you can provide
        #   your own via `:kms_client`. To only support decryption/reads, you may
        #   provide `:allow_decrypt_with_any_cmk` which will use
        #   the implicit CMK associated with the data during reads but will
        #   not allow you to encrypt/write objects with this client.
        #
        # @option options [#key_for] :key_provider Any object that responds
        #   to `#key_for`. This method should accept a materials description
        #   JSON document string and return return an encryption key.
        #
        # @option options [required, Symbol] :key_wrap_schema The Key wrapping
        #   schema to be used. It must match the type of key configured.
        #   Must be one of the following:
        #
        #   * :kms_context  (Must provide kms_key_id)
        #   * :aes_gcm (Must provide an AES (string) key)
        #   * :rsa_oaep_sha1 (Must provide an RSA key)
        #
        # @option options [required, Symbol] :content_encryption_schema
        #   Must be one of the following:
        #
        #   * :aes_gcm_no_padding
        #
        # @option options [Required, Symbol] :security_profile
        #   Determines the support for reading objects written using older
        #   key wrap or content encryption schemas.
        #   Must be one of the following:
        #
        #   * :v2 - Reads of legacy (v1) objects are NOT allowed
        #   * :v2_and_legacy - Enables reading of legacy (V1) schemas.
        #
        # @option options [Symbol] :envelope_location (:metadata) Where to
        #   store the envelope encryption keys. By default, the envelope is
        #   stored with the encrypted object. If you pass `:instruction_file`,
        #   then the envelope is stored in a separate object in Amazon S3.
        #
        # @option options [String] :instruction_file_suffix ('.instruction')
        #   When `:envelope_location` is `:instruction_file` then the
        #   instruction file uses the object key with this suffix appended.
        #
        # @option options [KMS::Client] :kms_client A default {KMS::Client}
        #   is constructed when using KMS to manage encryption keys.
        #
        def initialize(options = {})
          validate_params(options)
          @client = extract_client(options)
          @cipher_provider = cipher_provider(options)
          @envelope_location = extract_location(options)
          @instruction_file_suffix = extract_suffix(options)
          @kms_allow_decrypt_with_any_cmk =
            options[:kms_key_id] == :kms_allow_decrypt_with_any_cmk
          @security_profile = extract_security_profile(options)
        end

        # @return [S3::Client]
        attr_reader :client

        # @return [KeyProvider, nil] Returns `nil` if you are using
        #   AWS Key Management Service (KMS).
        attr_reader :key_provider

        # @return [Symbol] Determines the support for reading objects written
        #   using older key wrap or content encryption schemas.
        attr_reader :security_profile

        # @return [Boolean] If true the provided KMS key_id will not be used
        #   during decrypt, allowing decryption with the key_id from the object.
        attr_reader :kms_allow_decrypt_with_any_cmk

        # @return [Symbol<:metadata, :instruction_file>]
        attr_reader :envelope_location

        # @return [String] When {#envelope_location} is `:instruction_file`,
        #   the envelope is stored in the object with the object key suffixed
        #   by this string.
        attr_reader :instruction_file_suffix

        # Uploads an object to Amazon S3, encrypting data client-side.
        # See {S3::Client#put_object} for documentation on accepted
        # request parameters.
        # @option params [Hash] :kms_encryption_context Additional encryption
        #   context to use with KMS.  Applies only when KMS is used. In order
        #   to decrypt the object you will need to provide the identical
        #   :kms_encryption_context to `get_object`.
        # @option (see S3::Client#put_object)
        # @return (see S3::Client#put_object)
        # @see S3::Client#put_object
        def put_object(params = {})
          kms_encryption_context = params.delete(:kms_encryption_context)
          req = @client.build_request(:put_object, params)
          req.handlers.add(EncryptHandler, priority: 95)
          req.context[:encryption] = {
            cipher_provider: @cipher_provider,
            envelope_location: @envelope_location,
            instruction_file_suffix: @instruction_file_suffix,
            kms_encryption_context: kms_encryption_context
          }
          req.send_request
        end

        # Gets an object from Amazon S3, decrypting data locally.
        # See {S3::Client#get_object} for documentation on accepted
        # request parameters.
        # @option options [Symbol] :security_profile
        #   Determines the support for reading objects written using older
        #   key wrap or content encryption schemas. Overrides the value set
        #   on client construction if provided.
        #   Must be one of the following:
        #
        #   * :v2 - Reads of legacy (v1) objects are NOT allowed
        #   * :v2_and_legacy - Enables reading of legacy (V1) schemas.
        # @option params [String] :instruction_file_suffix The suffix
        #   used to find the instruction file containing the encryption
        #   envelope. You should not set this option when the envelope
        #   is stored in the object metadata. Defaults to
        #   {#instruction_file_suffix}.
        # @option params [Hash] :kms_encryption_context Additional encryption
        #   context to use with KMS.  Applies only when KMS is used.
        # @option options [Boolean] :kms_allow_decrypt_with_any_cmk (false)
        #   By default the KMS CMK ID (kms_key_id) will be used during decrypt
        #   and will fail if there is a mismatch.  Setting this to true
        #   will use the implicit CMK associated with the data.
        # @option (see S3::Client#get_object)
        # @return (see S3::Client#get_object)
        # @see S3::Client#get_object
        # @note The `:range` request parameter is not supported.
        def get_object(params = {}, &block)
          if params[:range]
            raise NotImplementedError, '#get_object with :range not supported'
          end
          envelope_location, instruction_file_suffix = envelope_options(params)
          kms_encryption_context = params.delete(:kms_encryption_context)
          kms_any_cmk_mode = kms_any_cmk_mode(params)
          security_profile = security_profile_from_params(params)

          req = @client.build_request(:get_object, params)
          req.handlers.add(DecryptHandler)
          req.context[:encryption] = {
            cipher_provider: @cipher_provider,
            envelope_location: envelope_location,
            instruction_file_suffix: instruction_file_suffix,
            kms_encryption_context: kms_encryption_context,
            kms_allow_decrypt_with_any_cmk: kms_any_cmk_mode,
            security_profile: security_profile
          }
          req.send_request(target: block)
        end

        private

        # Validate required parameters exist and don't conflict.
        # The cek_alg and wrap_alg are passed on to the CipherProviders
        # and further validated there
        def validate_params(options)
          unless (missing_params = REQUIRED_PARAMS - options.keys).empty?
            raise ArgumentError, "Missing required parameter(s): "\
              "#{missing_params.map{ |s| ":#{s}" }.join(', ')}"
          end

          wrap_alg = options[:key_wrap_schema]

          # validate that the wrap alg matches the type of key given
          case wrap_alg
          when :kms_context
            unless options[:kms_key_id]
              raise ArgumentError, 'You must provide :kms_key_id to use :kms_context'
            end
          end
        end

        def extract_client(options)
          options[:client] || begin
            options = options.dup
            options.delete(:kms_key_id)
            options.delete(:kms_client)
            options.delete(:key_provider)
            options.delete(:encryption_key)
            options.delete(:envelope_location)
            options.delete(:instruction_file_suffix)
            REQUIRED_PARAMS.each { |p| options.delete(p) }
            S3::Client.new(options)
          end
        end

        def kms_client(options)
          options[:kms_client] || begin
            KMS::Client.new(
              region: @client.config.region,
              credentials: @client.config.credentials,
              )
          end
        end

        def cipher_provider(options)
          if options[:kms_key_id]
            KmsCipherProvider.new(
              kms_key_id: options[:kms_key_id],
              kms_client: kms_client(options),
              key_wrap_schema: options[:key_wrap_schema],
              content_encryption_schema: options[:content_encryption_schema]
            )
          else
            @key_provider = extract_key_provider(options)
            DefaultCipherProvider.new(
              key_provider: @key_provider,
              key_wrap_schema: options[:key_wrap_schema],
              content_encryption_schema: options[:content_encryption_schema]
            )
          end
        end

        def extract_key_provider(options)
          if options[:key_provider]
            options[:key_provider]
          elsif options[:encryption_key]
            DefaultKeyProvider.new(options)
          else
            msg = 'you must pass a :kms_key_id, :key_provider, or :encryption_key'
            raise ArgumentError, msg
          end
        end

        def envelope_options(params)
          location = params.delete(:envelope_location) || @envelope_location
          suffix = params.delete(:instruction_file_suffix)
          if suffix
            [:instruction_file, suffix]
          else
            [location, @instruction_file_suffix]
          end
        end

        def extract_location(options)
          location = options[:envelope_location] || :metadata
          if [:metadata, :instruction_file].include?(location)
            location
          else
            msg = ':envelope_location must be :metadata or :instruction_file '\
                  "got #{location.inspect}"
            raise ArgumentError, msg
          end
        end

        def extract_suffix(options)
          suffix = options[:instruction_file_suffix] || '.instruction'
          if suffix.is_a? String
            suffix
          else
            msg = ':instruction_file_suffix must be a String'
            raise ArgumentError, msg
          end
        end

        def kms_any_cmk_mode(params)
          if !params[:kms_allow_decrypt_with_any_cmk].nil?
            params.delete(:kms_allow_decrypt_with_any_cmk)
          else
            @kms_allow_decrypt_with_any_cmk
          end
        end

        def extract_security_profile(options)
          validate_security_profile(options[:security_profile])
        end

        def security_profile_from_params(params)
          security_profile =
            if !params[:security_profile].nil?
              params.delete(:security_profile)
            else
              @security_profile
            end
          validate_security_profile(security_profile)
        end

        def validate_security_profile(security_profile)
          unless SUPPORTED_SECURITY_PROFILES.include? security_profile
            raise ArgumentError, "Unsupported security profile: :#{security_profile}. " \
            "Please provide one of: #{SUPPORTED_SECURITY_PROFILES.map { |s| ":#{s}" }.join(', ')}"
          end
          if security_profile == :v2_and_legacy && !@warned_about_legacy
            @warned_about_legacy = true
            warn(
              'The S3 Encryption Client is configured to read encrypted objects ' \
              "with legacy encryption modes. If you don't have objects " \
              'encrypted with these legacy modes, you should disable support ' \
              'for them to enhance security.'
            )
          end
          security_profile
        end
      end
    end
  end
end
