attr-encrypted/attr_encrypted

View on GitHub
lib/attr_encrypted.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

require 'encryptor'

# Adds attr_accessors that encrypt and decrypt an object's attributes
module AttrEncrypted
  autoload :Version, 'attr_encrypted/version'

  def self.extended(base) # :nodoc:
    base.class_eval do
      include InstanceMethods
      attr_writer :attr_encrypted_options
      @attr_encrypted_options, @attr_encrypted_encrypted_attributes = {}, {}
    end
  end

  # Generates attr_accessors that encrypt and decrypt attributes transparently
  #
  # Options (any other options you specify are passed to the Encryptor's encrypt and decrypt methods)
  #
  #   attribute:            The name of the referenced encrypted attribute. For example
  #                         <tt>attr_accessor :email, attribute: :ee</tt> would generate an
  #                         attribute named 'ee' to store the encrypted email. This is useful when defining
  #                         one attribute to encrypt at a time or when the :prefix and :suffix options
  #                         aren't enough.
  #                         Defaults to nil.
  #
  #   prefix:               A prefix used to generate the name of the referenced encrypted attributes.
  #                         For example <tt>attr_accessor :email, prefix: 'crypted_'</tt> would
  #                         generate attributes named 'crypted_email' to store the encrypted
  #                         email and password.
  #                         Defaults to 'encrypted_'.
  #
  #   suffix:               A suffix used to generate the name of the referenced encrypted attributes.
  #                         For example <tt>attr_accessor :email, prefix: '', suffix: '_encrypted'</tt>
  #                         would generate attributes named 'email_encrypted' to store the
  #                         encrypted email.
  #                         Defaults to ''.
  #
  #   key:                  The encryption key. This option may not be required if
  #                         you're using a custom encryptor. If you pass a symbol
  #                         representing an instance method then the :key option
  #                         will be replaced with the result of the method before
  #                         being passed to the encryptor. Objects that respond
  #                         to :call are evaluated as well (including procs).
  #                         Any other key types will be passed directly to the encryptor.
  #                         Defaults to nil.
  #
  #   encode:               If set to true, attributes will be encoded as well as
  #                         encrypted. This is useful if you're planning on storing
  #                         the encrypted attributes in a database. The default
  #                         encoding is 'm' (base64), however this can be overwritten
  #                         by setting the :encode option to some other encoding
  #                         string instead of just 'true'. See
  #                         http://www.ruby-doc.org/core/classes/Array.html#M002245
  #                         for more encoding directives.
  #                         Defaults to false unless you're using it with ActiveRecord or Sequel.
  #
  #   encode_iv:            Defaults to true.

  #   encode_salt:          Defaults to true.
  #
  #   default_encoding:     Defaults to 'm' (base64).
  #
  #   marshal:              If set to true, attributes will be marshaled as well
  #                         as encrypted. This is useful if you're planning on
  #                         encrypting something other than a string.
  #                         Defaults to false.
  #
  #   marshaler:            The object to use for marshaling.
  #                         Defaults to Marshal.
  #
  #   dump_method:          The dump method name to call on the <tt>:marshaler</tt> object to.
  #                         Defaults to 'dump'.
  #
  #   load_method:          The load method name to call on the <tt>:marshaler</tt> object.
  #                         Defaults to 'load'.
  #
  #   encryptor:            The object to use for encrypting.
  #                         Defaults to Encryptor.
  #
  #   encrypt_method:       The encrypt method name to call on the <tt>:encryptor</tt> object.
  #                         Defaults to 'encrypt'.
  #
  #   decrypt_method:       The decrypt method name to call on the <tt>:encryptor</tt> object.
  #                         Defaults to 'decrypt'.
  #
  #   if:                   Attributes are only encrypted if this option evaluates
  #                         to true. If you pass a symbol representing an instance
  #                         method then the result of the method will be evaluated.
  #                         Any objects that respond to <tt>:call</tt> are evaluated as well.
  #                         Defaults to true.
  #
  #   unless:               Attributes are only encrypted if this option evaluates
  #                         to false. If you pass a symbol representing an instance
  #                         method then the result of the method will be evaluated.
  #                         Any objects that respond to <tt>:call</tt> are evaluated as well.
  #                         Defaults to false.
  #
  #   mode:                 Selects encryption mode for attribute: choose <tt>:single_iv_and_salt</tt> for compatibility
  #                         with the old attr_encrypted API: the IV is derived from the encryption key by the underlying Encryptor class; salt is not used.
  #                         The <tt>:per_attribute_iv_and_salt</tt> mode uses a per-attribute IV and salt. The salt is used to derive a unique key per attribute.
  #                         A <tt>:per_attribute_iv</default> mode derives a unique IV per attribute; salt is not used.
  #                         Defaults to <tt>:per_attribute_iv</tt>.
  #
  #   allow_empty_value:    Attributes which have nil or empty string values will not be encrypted unless this option
  #                         has a truthy value.
  #
  # You can specify your own default options
  #
  #   class User
  #     # Now all attributes will be encoded and marshaled by default
  #     attr_encrypted_options.merge!(encode: true, marshal: true, some_other_option: true)
  #     attr_encrypted :configuration, key: 'my secret key'
  #   end
  #
  #
  # Example
  #
  #   class User
  #     attr_encrypted :email, key: 'some secret key'
  #     attr_encrypted :configuration, key: 'some other secret key', marshal: true
  #   end
  #
  #   @user = User.new
  #   @user.encrypted_email # nil
  #   @user.email? # false
  #   @user.email = 'test@example.com'
  #   @user.email? # true
  #   @user.encrypted_email # returns the encrypted version of 'test@example.com'
  #
  #   @user.configuration = { time_zone: 'UTC' }
  #   @user.encrypted_configuration # returns the encrypted version of configuration
  #
  #   See README for more examples
  def attr_encrypted(*attributes)
    options = attributes.last.is_a?(Hash) ? attributes.pop : {}
    options = attr_encrypted_default_options.dup.merge!(attr_encrypted_options).merge!(options)

    options[:encode] = options[:default_encoding] if options[:encode] == true
    options[:encode_iv] = options[:default_encoding] if options[:encode_iv] == true
    options[:encode_salt] = options[:default_encoding] if options[:encode_salt] == true

    attributes.each do |attribute|
      encrypted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym

      instance_methods_as_symbols = attribute_instance_methods_as_symbols

      if attribute_instance_methods_as_symbols_available?
        attr_reader encrypted_attribute_name unless instance_methods_as_symbols.include?(encrypted_attribute_name)
        attr_writer encrypted_attribute_name unless instance_methods_as_symbols.include?(:"#{encrypted_attribute_name}=")

        iv_name = "#{encrypted_attribute_name}_iv".to_sym
        attr_reader iv_name unless instance_methods_as_symbols.include?(iv_name)
        attr_writer iv_name unless instance_methods_as_symbols.include?(:"#{iv_name}=")

        salt_name = "#{encrypted_attribute_name}_salt".to_sym
        attr_reader salt_name unless instance_methods_as_symbols.include?(salt_name)
        attr_writer salt_name unless instance_methods_as_symbols.include?(:"#{salt_name}=")
      end

      define_method(attribute) do
        instance_variable_get("@#{attribute}") || instance_variable_set("@#{attribute}", attr_encrypted_decrypt(attribute, send(encrypted_attribute_name)))
      end

      define_method("#{attribute}=") do |value|
        send("#{encrypted_attribute_name}=", attr_encrypted_encrypt(attribute, value))
        instance_variable_set("@#{attribute}", value)
      end

      define_method("#{attribute}?") do
        value = send(attribute)
        value.respond_to?(:empty?) ? !value.empty? : !!value
      end

      self.attr_encrypted_encrypted_attributes[attribute.to_sym] = options.merge(attribute: encrypted_attribute_name)
    end
  end

  alias_method :attr_encryptor, :attr_encrypted

  # Default options to use with calls to <tt>attr_encrypted</tt>
  #
  # It will inherit existing options from its superclass
  def attr_encrypted_options
    @attr_encrypted_options ||= superclass.attr_encrypted_options.dup
  end

  def attr_encrypted_default_options
    {
      prefix:            'encrypted_',
      suffix:            '',
      if:                true,
      unless:            false,
      encode:            false,
      encode_iv:         true,
      encode_salt:       true,
      default_encoding:  'm',
      marshal:           false,
      marshaler:         Marshal,
      dump_method:       'dump',
      load_method:       'load',
      encryptor:         Encryptor,
      encrypt_method:    'encrypt',
      decrypt_method:    'decrypt',
      mode:              :per_attribute_iv,
      algorithm:         'aes-256-gcm',
      allow_empty_value: false,
    }
  end

  private :attr_encrypted_default_options

  # Checks if an attribute is configured with <tt>attr_encrypted</tt>
  #
  # Example
  #
  #   class User
  #     attr_accessor :name
  #     attr_encrypted :email
  #   end
  #
  #   User.attr_encrypted?(:name)  # false
  #   User.attr_encrypted?(:email) # true
  def attr_encrypted?(attribute)
    attr_encrypted_encrypted_attributes.has_key?(attribute.to_sym)
  end

  # Decrypts a value for the attribute specified
  #
  # Example
  #
  #   class User
  #     attr_encrypted :email
  #   end
  #
  #   email = User.attr_encrypted_decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING')
  def attr_encrypted_decrypt(attribute, encrypted_value, options = {})
    options = attr_encrypted_encrypted_attributes[attribute.to_sym].merge(options)
    if options[:if] && !options[:unless] && not_empty?(encrypted_value)
      encrypted_value = encrypted_value.unpack(options[:encode]).first if options[:encode]
      value = options[:encryptor].send(options[:decrypt_method], options.merge!(value: encrypted_value))
      if options[:marshal]
        value = options[:marshaler].send(options[:load_method], value)
      elsif defined?(Encoding)
        encoding = Encoding.default_internal || Encoding.default_external
        value = value.force_encoding(encoding.name)
      end
      value
    else
      encrypted_value
    end
  end

  # Encrypts a value for the attribute specified
  #
  # Example
  #
  #   class User
  #     attr_encrypted :email
  #   end
  #
  #   encrypted_email = User.attr_encrypted_encrypt(:email, 'test@example.com')
  def attr_encrypted_encrypt(attribute, value, options = {})
    options = attr_encrypted_encrypted_attributes[attribute.to_sym].merge(options)
    if options[:if] && !options[:unless] && (options[:allow_empty_value] || not_empty?(value))
      value = options[:marshal] ? options[:marshaler].send(options[:dump_method], value) : value.to_s
      encrypted_value = options[:encryptor].send(options[:encrypt_method], options.merge!(value: value))
      encrypted_value = [encrypted_value].pack(options[:encode]) if options[:encode]
      encrypted_value
    else
      value
    end
  end

  def not_empty?(value)
    !value.nil? && !(value.is_a?(String) && value.empty?)
  end

  # Contains a hash of encrypted attributes with virtual attribute names as keys
  # and their corresponding options as values
  #
  # Example
  #
  #   class User
  #     attr_encrypted :email, key: 'my secret key'
  #   end
  #
  #   User.attr_encrypted_encrypted_attributes # { email: { attribute: 'encrypted_email', key: 'my secret key' } }
  def attr_encrypted_encrypted_attributes
    @attr_encrypted_encrypted_attributes ||= superclass.attr_encrypted_encrypted_attributes.dup
  end

  # Forwards calls to :encrypt_#{attribute} or :decrypt_#{attribute} to the corresponding encrypt or decrypt method
  # if attribute was configured with attr_encrypted
  #
  # Example
  #
  #   class User
  #     attr_encrypted :email, key: 'my secret key'
  #   end
  #
  #   User.encrypt_email('SOME_ENCRYPTED_EMAIL_STRING')
  def method_missing(method, *arguments, &block)
    if method.to_s =~ /^((en|de)crypt)_(.+)$/ && attr_encrypted?($3)
      send("attr_encrypted_#{$1}", $3, *arguments)
    else
      super
    end
  end

  module InstanceMethods
    # Decrypts a value for the attribute specified using options evaluated in the current object's scope
    #
    # Example
    #
    #  class User
    #    attr_accessor :secret_key
    #    attr_encrypted :email, key: :secret_key
    #
    #    def initialize(secret_key)
    #      self.secret_key = secret_key
    #    end
    #  end
    #
    #  @user = User.new('some-secret-key')
    #  @user.decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING')
    def attr_encrypted_decrypt(attribute, encrypted_value)
      attr_encrypted_encrypted_attributes[attribute.to_sym][:operation] = :decrypting
      attr_encrypted_encrypted_attributes[attribute.to_sym][:value_present] = self.class.not_empty?(encrypted_value)
      self.class.attr_encrypted_decrypt(attribute, encrypted_value, evaluated_attr_encrypted_options_for(attribute))
    end

    # Encrypts a value for the attribute specified using options evaluated in the current object's scope
    #
    # Example
    #
    #  class User
    #    attr_accessor :secret_key
    #    attr_encrypted :email, key: :secret_key
    #
    #    def initialize(secret_key)
    #      self.secret_key = secret_key
    #    end
    #  end
    #
    #  @user = User.new('some-secret-key')
    #  @user.attr_encrypted_encrypt(:email, 'test@example.com')
    def attr_encrypted_encrypt(attribute, value)
      attr_encrypted_encrypted_attributes[attribute.to_sym][:operation] = :encrypting
      attr_encrypted_encrypted_attributes[attribute.to_sym][:value_present] = self.class.not_empty?(value)
      self.class.attr_encrypted_encrypt(attribute, value, evaluated_attr_encrypted_options_for(attribute))
    end

    # Copies the class level hash of encrypted attributes with virtual attribute names as keys
    # and their corresponding options as values to the instance
    #
    def attr_encrypted_encrypted_attributes
      @attr_encrypted_encrypted_attributes ||= begin
        duplicated= {}
        self.class.attr_encrypted_encrypted_attributes.map { |key, value| duplicated[key] = value.dup }
        duplicated
      end
    end

    protected

      # Returns attr_encrypted options evaluated in the current object's scope for the attribute specified
      def evaluated_attr_encrypted_options_for(attribute)
        evaluated_options = Hash.new
        attributes = attr_encrypted_encrypted_attributes[attribute.to_sym]
        attribute_option_value = attributes[:attribute]

        [:if, :unless, :value_present, :allow_empty_value].each do |option|
          evaluated_options[option] = evaluate_attr_encrypted_option(attributes[option])
        end

        evaluated_options[:attribute] = attribute_option_value

        evaluated_options.tap do |options|
          if options[:if] && !options[:unless] && options[:value_present] || options[:allow_empty_value]
            (attributes.keys - evaluated_options.keys).each do |option|
              options[option] = evaluate_attr_encrypted_option(attributes[option])
            end

            unless options[:mode] == :single_iv_and_salt
              load_iv_for_attribute(attribute, options)
            end

            if options[:mode] == :per_attribute_iv_and_salt
              load_salt_for_attribute(attribute, options)
            end
          end
        end
      end

      # Evaluates symbol (method reference) or proc (responds to call) options
      #
      # If the option is not a symbol or proc then the original option is returned
      def evaluate_attr_encrypted_option(option)
        if option.is_a?(Symbol) && respond_to?(option, true)
          send(option)
        elsif option.respond_to?(:call)
          option.call(self)
        else
          option
        end
      end

      def load_iv_for_attribute(attribute, options)
        encrypted_attribute_name = options[:attribute]
        encode_iv = options[:encode_iv]
        iv = options[:iv] || send("#{encrypted_attribute_name}_iv")
        if options[:operation] == :encrypting
          begin
            iv = generate_iv(options[:algorithm])
            iv = [iv].pack(encode_iv) if encode_iv
            send("#{encrypted_attribute_name}_iv=", iv)
          rescue RuntimeError
          end
        end
        if iv && !iv.empty?
          iv = iv.unpack(encode_iv).first if encode_iv
          options[:iv] = iv
        end
      end

      def generate_iv(algorithm)
        algo = OpenSSL::Cipher.new(algorithm)
        algo.encrypt
        algo.random_iv
      end

      def load_salt_for_attribute(attribute, options)
        encrypted_attribute_name = options[:attribute]
        encode_salt = options[:encode_salt]
        salt = options[:salt] || send("#{encrypted_attribute_name}_salt")
        if options[:operation] == :encrypting
          salt = SecureRandom.random_bytes
          salt = prefix_and_encode_salt(salt, encode_salt) if encode_salt
          send("#{encrypted_attribute_name}_salt=", salt)
        end
        if salt && !salt.empty?
          salt = decode_salt_if_encoded(salt, encode_salt) if encode_salt
          options[:salt] = salt
        end
      end

      def prefix_and_encode_salt(salt, encoding)
        prefix = '_'
        prefix + [salt].pack(encoding)
      end

      def decode_salt_if_encoded(salt, encoding)
        prefix = '_'
        salt.slice(0).eql?(prefix) ? salt.slice(1..-1).unpack(encoding).first : salt
      end
  end

  protected

  def attribute_instance_methods_as_symbols
    instance_methods.collect { |method| method.to_sym }
  end

  def attribute_instance_methods_as_symbols_available?
    true
  end

end


Dir[File.join(File.dirname(__FILE__), 'attr_encrypted', 'adapters', '*.rb')].each { |adapter| require adapter }