require 'singleton'
require 'attr_encryption/date_extensions'
require 'attr_encryption/mysql_encryption'
require 'attr_encryption/mysql_encryptor'
# Adds attr_accessors that encrypt and decrypt an object's attributes
module AttrEncryption
def self.extended(base) # :nodoc:
base.class_eval do
include InstanceMethods
attr_writer :attr_encrypted_options
@attr_encrypted_options, @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.
#
# :type => The data type of the value to be encrypted/decrypted. Can be 'date', 'datetime', 'binary', 'text' or json.
# When encrypting, all values will use their string value (value.to_s). When decrypting,
# the type of the value will determine what is returned. For example:
#
# type = 'date': Date.parse(decrypted_value)
# type = 'time': DateTime.parse(decrypted_value)
# type = 'binary': decrypted_value
# type = 'text': decrypted_value.force_encoding('utf-8')
# type = 'json': JSON.parse(decrypted_value)
#
# :prefix => A prefix used to generate the name of the referenced encrypted attributes.
# For example attr_accessor :email, :password, :prefix => 'crypted_' would
# generate attributes named 'crypted_email' and 'crypted_password' to store the
# encrypted email and password. Defaults to ''.
#
# :suffix => A suffix used to generate the name of the referenced encrypted attributes.
# For example attr_accessor :email, :password, :suffix => '_encrypted'
# would generate attributes named 'email_encrypted' and 'password_encrypted' to store the
# encrypted email. Defaults to '_enc'.
#
# :preencrypt => The symbol identifying a method that should be run on an attribute immediately prior
# to marshalling and encrypting. This could be used for things like stripping white-space
# from values or other sorts of pre-processing. Defaults to nil.
#
# :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. TODO (DJS): We'll see if we need this.
#
# :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. TODO(DJS): We'll see if we need this.
#
# :default_encoding => Defaults to 'm' (base64). TODO(DJS): Hmmm. See above
#
# :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 unless you're using it with ActiveRecord
# or DataMapper. TODO(DJS): Don't want to use this by default in our encryption since we want to be able to query...
#
# :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. TODO(DJS): Need to changed this to indicate our encryptor
#
# :encrypt_method => The encrypt method name to call on the :encryptor object. Defaults to 'encrypt'. TODO(DJS): Verify this.
#
# :decrypt_method => The decrypt method name to call on the :encryptor object. Defaults to 'decrypt'. TODO(DJS): Verify this.
#
# :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.
#
# You can specify your own default options
#
# TODO(DJS): Need to rework the examples.
# 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, :credit_card, :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 = {
:prefix => '',
:suffix => '_enc',
:if => true,
:unless => false,
:encode => false,
:key => $encryption_key,
:type => 'text',
:default_encoding => 'm',
:preenrypt => nil,
:marshal => false,
:marshaler => Marshal,
:dump_method => 'dump',
:load_method => 'load',
:encryptor => MySQLEncryptor.instance,
:encrypt_method => 'encrypt',
:decrypt_method => 'decrypt'
}.merge!(attr_encrypted_options).merge!(attributes.last.is_a?(Hash) ? attributes.pop : {})
options[:encode] = options[:default_encoding] if options[:encode] == true
attributes.each do |attribute|
encrypted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym
instance_methods_as_symbols = instance_methods.collect { |method| method.to_sym }
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}=")
define_method(attribute) do
cached_value = instance_variable_get("@#{attribute}")
if cached_value
case options[:type]
when 'date'
value = cached_value.is_a?(Date) ? cached_value : nil
when 'json'
value = cached_value.is_a?(Hash) || cached_value.is_a?(Array) ? cached_value : nil
else
value = cached_value
end
else
value = nil
end
value || instance_variable_set("@#{attribute}", decrypt(attribute, send(encrypted_attribute_name)))
end
define_method("#{attribute}=") do |value|
value_to_encrypt = options[:type] == 'json' ? value.to_json : value
send("#{encrypted_attribute_name}=", encrypt(attribute, value_to_encrypt))
instance_variable_set("@#{attribute}", value)
end
define_method("#{attribute}?") do
value = send(attribute)
value.respond_to?(:empty?) ? !value.empty? : !!value
end
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
# 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)
encrypted_attributes.has_key?(attribute.to_sym)
end
# Decrypts a value for the attribute specified
#
# Example
#
# class User
# attr_encrypted :email
# end
#
# email = User.decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING')
def decrypt(attribute, encrypted_value, options = {})
options = encrypted_attributes[attribute.to_sym].merge(options)
if options[:if] && !options[:unless] && !encrypted_value.nil? && !(encrypted_value.is_a?(String) && encrypted_value.empty?)
encrypted_value = encrypted_value.unpack(options[:encode]).first if options[:encode]
value = options[:encryptor].send(options[:decrypt_method], options.merge!(:value => encrypted_value))
value = options[:marshaler].send(options[:load_method], value) if options[:marshal]
value
else
encrypted_value
end
end
# Encrypts a value for the attribute specified
#
# Example
#
# class User
# attr_encrypted :email
# end
#
# encrypted_email = User.encrypt(:email, 'test@example.com')
def encrypt(attribute, value, options = {})
options = encrypted_attributes[attribute.to_sym].merge(options)
if options[:if] && !options[:unless] && !value.nil? && !(value.is_a?(String) && value.empty?)
value = options[:preencrypt] ? (value.is_a?(String) ? value.send(options[:preencrypt]) : value) : 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
cleanse_value value, options
end
end
# Cleans up the value to ensure we don't get empty strings the db when we should be getting nils.
def cleanse_value(value, options)
return nil if value.is_a?(String) && value.empty? && options[:type] == 'date'
value
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.encrypted_attributes # { :email => { :attribute => 'encrypted_email', :key => 'my secret key' } }
def encrypted_attributes
@encrypted_attributes ||= superclass.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 =~ /\A((en|de)crypt)_(.+)\z/ && attr_encrypted?($3)
send($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 decrypt(attribute, encrypted_value)
self.class.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.encrypt(:email, 'test@example.com')
def encrypt(attribute, value)
self.class.encrypt(attribute, value, evaluated_attr_encrypted_options_for(attribute))
end
def unencrypted_attributes
attributes.each_with_object({}) do |a, new_hash|
key = a.first
value = a.last
if key =~ /\A(.+)_enc\z/
key = $1
value = decrypt(key.to_sym, value)
end
new_hash[key] = value
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)
self.class.encrypted_attributes[attribute.to_sym].inject({}) { |hash, (option, value)| hash.merge!(option => (option == :preencrypt) ? value : evaluate_attr_encrypted_option(value)) }
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)
send(option)
elsif option.respond_to?(:call)
option.call(self)
else
option
end
end
end
end
Object.extend AttrEncryption
# require File.expand_path('attr_encryption/adapters/active_record.rb', File.dirname(__FILE__))
require 'attr_encryption/adapters/active_record.rb'