# 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_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 # attr_accessor :email, attribute: :ee 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 attr_accessor :email, prefix: 'crypted_' 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 attr_accessor :email, prefix: '', suffix: '_encrypted' # 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, DataMapper, 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 :marshaler object to. # Defaults to 'dump'. # # load_method: The load method name to call on the :marshaler object. # Defaults to 'load'. # # encryptor: The object to use for encrypting. # Defaults to Encryptor. # # encrypt_method: The encrypt method name to call on the :encryptor object. # Defaults to 'encrypt'. # # decrypt_method: The decrypt method name to call on the :encryptor 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 :call 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 :call are evaluated as well. # Defaults to false. # # mode: Selects encryption mode for attribute: choose :single_iv_and_salt 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 :per_attribute_iv_and_salt mode uses a per-attribute IV and salt. The salt is used to derive a unique key per attribute. # A :per_attribute_iv mode derives a unique IV per attribute; salt is not used. # Defaults to :per_attribute_iv. # # 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_decrypt(attribute, send(encrypted_attribute_name))) end define_method("#{attribute}=") do |value| send("#{encrypted_attribute_name}=", attr_encrypt(attribute, value)) instance_variable_set("@#{attribute}", value) end define_method("#{attribute}?") do value = send(attribute) value.respond_to?(:empty?) ? !value.empty? : !!value end attr_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 attr_encrypted # # 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 attr_encrypted # # 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_attributes.has_key?(attribute.to_sym) end # Decrypts a value for the attribute specified # # Example # # class User # attr_encrypted :email # end # # email = User.attr_decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING') def attr_decrypt(attribute, encrypted_value, options = {}) options = attr_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_encrypt(:email, 'test@example.com') def attr_encrypt(attribute, value, options = {}) options = attr_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_attributes # { email: { attribute: 'encrypted_email', key: 'my secret key' } } def attr_encrypted_attributes @attr_encrypted_attributes ||= superclass.attr_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_#{$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.attr_decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING') def attr_decrypt(attribute, encrypted_value) attr_encrypted_attributes[attribute.to_sym][:operation] = :decrypting attr_encrypted_attributes[attribute.to_sym][:value_present] = self.class.not_empty?(encrypted_value) self.class.attr_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_encrypt(:email, 'test@example.com') def attr_encrypt(attribute, value) attr_encrypted_attributes[attribute.to_sym][:operation] = :encrypting attr_encrypted_attributes[attribute.to_sym][:value_present] = self.class.not_empty?(value) self.class.attr_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_attributes @attr_encrypted_attributes ||= begin duplicated= {} self.class.attr_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_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 }