onddo/chef-encrypted-attributes

View on GitHub
lib/chef/encrypted_attribute/encrypted_mash/version1.rb

Summary

Maintainability
A
0 mins
Test Coverage
# encoding: UTF-8
#
# Author:: Xabier de Zuazo (<xabier@zuazo.org>)
# Copyright:: Copyright (c) 2014 Onddo Labs, SL.
# 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/encrypted_attribute/encrypted_mash/version0'
require 'chef/encrypted_attribute/exceptions'

class Chef
  class EncryptedAttribute
    class EncryptedMash
      # EncryptedMash Version1 format: using RSA with a shared secret and
      # message authentication (HMAC).
      #
      # This is the {EncryptedMash} version used by default. Uses public key
      # cryptography (PKI) to encrypt a shared secret. Then this shared secret
      # is used to encrypt the data.
      #
      # * This implementation can be improved, is not optimized either for
      #   performance or for space.
      # * Every time the `EncryptedAttribute` is updated, all the shared secrets
      #   are regenerated.
      #
      # # `EncryptedMash::Version1` Structure
      #
      # If you try to read this encrypted attribute structure, you can see a
      # `Mash` attribute with the following content:
      #
      # ```
      # EncryptedMash
      # ├── chef_type: "encrypted_attribute" (string).
      # ├── x_json_class: The used `EncryptedMash` version class name (string).
      # ├── encrypted_data
      # │   ├── cipher: The used PKI algorithm, "aes-256-cbc" (string).
      # │   ├── data: PKI encrypted data (base64).
      # │   └── iv: Initialization vector (in base64).
      # ├── encrypted_secret
      # │   ├── pub_key_hash1: The shared secrets encrypted for the public key 1
      # │   │     (base64).
      # │   ├── pub_key_hash2: The shared secrets encrypted for the public key 2
      # │   │     (base64).
      # │   └── ...
      # └── hmac
      #     ├── cipher: The used HMAC algorithm, currently ignored and always
      #     │     "sha256" (string).
      #     └── data: Hash-based message authentication code value (base64).
      # ```
      #
      # * `x_json_class` field is used, with the `x_` prefix, to be easily
      #   integrated with Chef in the future.
      #
      # ## `EncryptedMash[encrypted_data][data]`
      #
      # The data inside `encrypted_data` is symmetrically encrypted using the
      # secret shared key. The data is converted to *JSON* before the
      # encryption, then encrypted and finally encoded in *base64*. By default,
      # the `'aes-256-cbc'` algorithm is used for encryption.
      #
      # After decryption, the *JSON* has the following structure:
      #
      # ```
      # └── encrypted_data
      #     └── data (symmetrically encrypted JSON in base64)
      #         └── content: attribute content as a Mash.
      # ```
      #
      # * In the future, this structure may contain some metadata like default
      #   configuration values.
      #
      # ## `EncryptedMash[encrypted_secret][pub_key_hash1]`
      #
      # The `public_key_hash1` key value is the *SHA1* of the public key used
      # for encryption.
      #
      # Its content is the encrypted shared secrets in *base64*. The encryption
      # is done using the *RSA* algorithm (PKI).
      #
      # After decryption, you find the following structure in *JSON*:
      #
      # ```
      # └── encrypted_secret
      #     └── pub_key_hash1 (PKI encrypted JSON in base64)
      #         ├── data: The shared secret used to encrypt the data (base64).
      #         └── hmac: The shared secret used for the HMAC calculation
      #               (base64).
      # ```
      #
      # ## `EncryptedMash[hmac][data]`
      #
      # The HMAC data is in *base64*. The hashing algorithm used is `'sha256'`.
      #
      # The following data is used in a alphabetically sorted *JSON* to
      # calculate the HMAC:
      #
      # ```
      # Data to calculate the HMAC from
      # ├── cipher: The algorithm used for `encrypted_data` encryption
      # │     ("aes-256-cbc").
      # ├── data: The `encrypted_data` data content after the encryption
      # │     (encrypt-then-mac).
      # └── iv: The initialization vector used to encrypt the encrypted_data.
      # ```
      #
      # * All the data required for decryption is included in the HMAC (except
      #   the secret key, of course): `cipher`, `data` and `iv`.
      # * The data used to calculate the HMAC is the encrypted data, not the
      #   clear text data (**Encrypt-then-MAC**).
      # * The secret used to calculate the HMAC is not the same as the secret
      #   used to encrypt the data.
      # * The secret used to calculate the HMAC is shared inside
      #   `encrypted_secret` field with the data secret.
      #
      # @see EncryptedMash
      class Version1 < Chef::EncryptedAttribute::EncryptedMash::Version0
        # Symmetric algorithm to use by default.
        SYMM_ALGORITHM = 'aes-256-cbc'
        # Algorithm used for HMAC calculation.
        HMAC_ALGORITHM = 'sha256'

        # (see EncryptedMash::Version0#encrypt)
        # @raise [MessageAuthenticationFailure] if HMAC calculation error.
        def encrypt(value, public_keys)
          secrets = {}
          value_json = json_encode(value)
          public_keys = parse_public_keys(public_keys)
          # encrypt the data
          encrypted_data = symmetric_encrypt_value(value_json)
          # should no include the secret in clear
          secrets['data'] = encrypted_data.delete('secret')
          self['encrypted_data'] = encrypted_data
          # generate hmac (encrypt-then-mac), excluding the secret
          hmac = generate_hmac(json_encode(self['encrypted_data'].sort))
          secrets['hmac'] = hmac.delete('secret')
          self['hmac'] = hmac
          # encrypt the shared secrets
          self['encrypted_secret'] =
            rsa_encrypt_multi_key(json_encode(secrets), public_keys)
          self
        end

        # (see EncryptedMash::Version0#decrypt)
        # @raise [MessageAuthenticationFailure] if HMAC calculation error.
        def decrypt(key)
          key = parse_decryption_key(key)
          enc_value = self['encrypted_data'].dup
          hmac = self['hmac'].dup
          # decrypt the shared secrets
          secrets =
            json_decode(rsa_decrypt_multi_key(self['encrypted_secret'], key))
          enc_value['secret'] = secrets['data']
          hmac['secret'] = secrets['hmac']
          # check hmac (encrypt-then-mac -> mac-then-decrypt)
          unless hmac_matches?(hmac, json_encode(self['encrypted_data'].sort))
            fail DecryptionFailure,
                 'Error decrypting encrypted attribute: invalid hmac. Most '\
                 'likely the data is corrupted.'
          end
          # decrypt the data
          value_json = symmetric_decrypt_value(enc_value)
          json_decode(value_json)
        end

        # (see EncryptedMash::Version0#can_be_decrypted_by?)
        def can_be_decrypted_by?(keys)
          return false unless encrypted?
          data_can_be_decrypted_by_keys?(self['encrypted_secret'], keys)
        end

        # (see EncryptedMash::Version0#needs_update?)
        def needs_update?(keys)
          keys = parse_public_keys(keys)
          !can_be_decrypted_by?(keys) ||
            self['encrypted_secret'].keys.count != keys.count
        end

        protected

        # Checks if the encrypted data Mash contains all the fields an has the
        # correct type.
        #
        # Checks the `self['encrypted_data']` structure.
        #
        # @param [Hash<Symbol, Class>] fields to check. For example:
        #   `{ iv: String, data: String }`.
        # @return [Boolean] `true` if all the fields are exists.
        def encrypted_data_contain_fields?(fields)
          data = self['encrypted_data']
          fields.reduce(true) do |r, (field, kind_of)|
            r && data.key?(field) && data[field].is_a?(kind_of)
          end
        end

        # Checks if the encrypted data structure is correct.
        #
        # Checks the `self['encrypted_data']` structure.
        #
        # @return [Boolean] `true` if it is correct.
        def encrypted_data?
          encrypted_data_contain_fields?(iv: String, data: String)
        end

        # Checks if the encrypted secrets structure is correct.
        #
        # Checks the `self['encrypted_secret']` structure.
        #
        # @return [Boolean] `true` if it is correct.
        def encrypted_secret?
          self['encrypted_secret'].is_a?(Hash)
        end

        # Checks if the HMAC structure is correct.
        #
        # Checks the `self['hmac']` structure.
        #
        # @return [Boolean] `true` if it is correct.
        def encrypted_hmac?
          self['hmac'].is_a?(Hash) &&
            self['hmac'].key?('data') &&
            self['hmac']['data'].is_a?(String)
        end

        # (see EncryptedMash::Version0#encrypted?)
        def encrypted?
          super &&
            encrypted_data? &&
            encrypted_secret? &&
            encrypted_hmac?
        end

        # Encrypts a value using a symmetric cryptographic algorithm.
        #
        # Uses a randomly generated secret and IV.
        #
        # @param value [String] data to encrypt.
        # @param algo [String] symmetric algorithm to use.
        # @return [Mash] hash structure with symmetrically encrypted data:
        #   * `['cipher']`: algorithm used.
        #   * `['secret']`: random secret used for encryption in Base64.
        #   * `['iv']`: random initialization vector in Base64.
        #   * `['data']`: data encrypted and in Base64.
        # @raise [EncryptionFailure] if encryption error.
        def symmetric_encrypt_value(value, algo = SYMM_ALGORITHM)
          enc_value = Mash.new('cipher' => algo)
          begin
            cipher = OpenSSL::Cipher.new(algo)
            cipher.encrypt
            enc_value['secret'] =
              Base64.encode64(cipher.key = cipher.random_key)
            enc_value['iv'] = Base64.encode64(cipher.iv = cipher.random_iv)
            enc_data = cipher.update(value) + cipher.final
          rescue OpenSSL::Cipher::CipherError => e
            raise EncryptionFailure, "#{e.class.name}: #{e}"
          end
          enc_value['data'] = Base64.encode64(enc_data)
          enc_value
        end

        # Decrypts data using a symmetric cryptographic algorithm.
        #
        # @param enc_value [Mash] hash structure with encrypted data:
        #   * `['cipher']`: algorithm used.
        #   * `['secret']`: secret used for encryption in Base64.
        #   * `['iv']`: initialization vector in Base64.
        #   * `['data']`: data encrypted in Base64.
        # @param algo [String] symmetric algorithm to use.
        # @raise [DecryptionFailure] if decryption error.
        # @see #symmetric_encrypt_value
        def symmetric_decrypt_value(enc_value, algo = SYMM_ALGORITHM)
          # TODO: maybe it's better to ignore [cipher] ?
          cipher = OpenSSL::Cipher.new(enc_value['cipher'] || algo)
          cipher.decrypt
          # We must set key before iv: https://bugs.ruby-lang.org/issues/8221
          cipher.key = Base64.decode64(enc_value['secret'])
          cipher.iv = Base64.decode64(enc_value['iv'])
          cipher.update(Base64.decode64(enc_value['data'])) + cipher.final
        rescue OpenSSL::Cipher::CipherError => e
          raise DecryptionFailure, "#{e.class.name}: #{e}"
        end

        # Exception list raised by the HMAC calculation process.
        #
        # `RuntimeError` is raised for unsupported algorithms.
        #
        # @api private
        HMAC_EXCEPTIONS =
          [OpenSSL::Digest::DigestError, OpenSSL::HMACError, RuntimeError]

        # Calculates [HMAC]
        # (http://en.wikipedia.org/wiki/Hash-based_message_authentication_code)
        # value for data.
        #
        # Uses a randomly generated secret for the HMAC calculation.
        #
        # @param data [String] data to calculate HMAC for.
        # @param algo [String] HMAC algorithm to use.
        # @return [Mash] hash structure with HMAC data:
        #   * `['cipher']`: algorithm used.
        #   * `['secret']` random secret used for HMAC calculation in Base64.
        #   * `['data']` HMAC value in Base64.
        # @raise [MessageAuthenticationFailure] if HMAC calculation error.
        def generate_hmac(data, algo = HMAC_ALGORITHM)
          # [cipher] is ignored, only as info
          hmac = Mash.new('cipher' => algo)
          digest = OpenSSL::Digest.new(algo)
          secret = OpenSSL::Random.random_bytes(digest.block_length)
          hmac['secret'] = Base64.encode64(secret)
          hmac['data'] =
            Base64.encode64(OpenSSL::HMAC.digest(digest, secret, data))
          hmac
        rescue *HMAC_EXCEPTIONS => e
          raise MessageAuthenticationFailure, "#{e.class}: #{e}"
        end

        # Checks if the [HMAC]
        # (http://en.wikipedia.org/wiki/Hash-based_message_authentication_code)
        # matches
        #
        # Uses a randomly generated secret for the HMAC calculation.
        #
        # @param orig_hmac [Mash] hash structure with HMAC data:
        #   * `['cipher']`: algorithm used (this is ignored).
        #   * `['secret']` secret used for HMAC calculation in Base64.
        #   * `['data']` HMAC value in Base64.
        # @param data [String] data to calculate HMAC for.
        # @param algo [String] HMAC algorithm to use.
        # @return [Boolean] `true` if HMAC value matches.
        # @raise [MessageAuthenticationFailure] if HMAC calculation error.
        # @see #generate_hmac
        def hmac_matches?(orig_hmac, data, algo = HMAC_ALGORITHM)
          digest = OpenSSL::Digest.new(algo)
          secret = Base64.decode64(orig_hmac['secret'])
          new_hmac = Base64.encode64(OpenSSL::HMAC.digest(digest, secret, data))
          orig_hmac['data'] == new_hmac
        rescue *HMAC_EXCEPTIONS => e
          raise MessageAuthenticationFailure, "#{e.class}: #{e}"
        end
      end
    end
  end
end