├── Gemfile ├── .gitignore ├── checksum ├── attr_encrypted-3.0.0.gem.sha256 ├── attr_encrypted-3.0.1.gem.sha256 ├── attr_encrypted-3.0.2.gem.sha256 ├── attr_encrypted-3.0.3.gem.sha256 ├── attr_encrypted-3.0.0.gem.sha512 ├── attr_encrypted-3.0.1.gem.sha512 ├── attr_encrypted-3.0.2.gem.sha512 └── attr_encrypted-3.0.3.gem.sha512 ├── test ├── run.sh ├── sequel_test.rb ├── data_mapper_test.rb ├── test_helper.rb ├── legacy_sequel_test.rb ├── legacy_data_mapper_test.rb ├── legacy_compatibility_test.rb ├── legacy_active_record_test.rb ├── compatibility_test.rb ├── legacy_attr_encrypted_test.rb ├── active_record_test.rb └── attr_encrypted_test.rb ├── lib ├── attr_encrypted │ ├── adapters │ │ ├── sequel.rb │ │ ├── data_mapper.rb │ │ └── active_record.rb │ └── version.rb └── attr_encrypted.rb ├── Rakefile ├── MIT-LICENSE ├── .travis.yml ├── certs └── saghaulor.pem ├── attr_encrypted.gemspec ├── CHANGELOG.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .DS_Store 3 | .ruby-version 4 | pkg 5 | Gemfile.lock 6 | coverage 7 | -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.0.gem.sha256: -------------------------------------------------------------------------------- 1 | 845fc3cb09a19c3ac76192aba443788f92c880744617bca99b16fd31ce843e07 -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.1.gem.sha256: -------------------------------------------------------------------------------- 1 | 33140af4b223177db7a19efb2fa38472a299a745b29ca1c5ba9d3fa947390b77 -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.2.gem.sha256: -------------------------------------------------------------------------------- 1 | c1256b459336d4a2012a0d0c70ce5cd3dac46acb5e78da6f77f6f104cb1e8b7b -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.3.gem.sha256: -------------------------------------------------------------------------------- 1 | 6d84c64852c4bbc0926b92fe7a93295671a9e69cb2939b96fb1e4b5e8a5b33b6 -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.0.gem.sha512: -------------------------------------------------------------------------------- 1 | 81a065442258cc3702aab62c7b2307a48ed3e0deb803600d11a7480cce0db7c43fd9929acd2755081042f8989236553fd694b6cb62776bbfc53f9165a22cbca1 -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.1.gem.sha512: -------------------------------------------------------------------------------- 1 | 0c467cab98b9b2eb331f9818323a90ae01392d6cb03cf1f32faccc954d0fc54be65f0fc7bf751b0fce57925eef1c9e2af90181bc40d81ad93e21d15a001c53c6 -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.2.gem.sha512: -------------------------------------------------------------------------------- 1 | dca0c8a729974c0e26fde4cd4216c7d0f66d9eca9f6cf0ccca64999f5180a00bf7c05b630c1d420ec1673141a2923946e8bd28b12e711faf64a4cd42c7a3ac9e -------------------------------------------------------------------------------- /checksum/attr_encrypted-3.0.3.gem.sha512: -------------------------------------------------------------------------------- 1 | 0f960e8a2f63c747c273241f7395dcceb0dd8a6f79349bee453db741fc7ea5ceb4342d7d5908e540e3b5acea2216ff38bef8c743e6e7c8559bacb4a731ab27c4 -------------------------------------------------------------------------------- /test/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh -e 2 | 3 | for RUBY in 1.9.3 2.0.0 2.1 2.2 4 | do 5 | for RAILS in 2.3.8 3.0.0 3.1.0 3.2.0 4.0.0 4.1.0 4.2.0 6 | do 7 | if [[ $RUBY -gt 1.9.3 && $RAILS -lt 4.0.0 ]]; then 8 | continue 9 | fi 10 | RBENV_VERSION=$RUBY ACTIVERECORD=$RAILS bundle && bundle exec rake 11 | done 12 | done 13 | -------------------------------------------------------------------------------- /lib/attr_encrypted/adapters/sequel.rb: -------------------------------------------------------------------------------- 1 | if defined?(Sequel) 2 | module AttrEncrypted 3 | module Adapters 4 | module Sequel 5 | def self.extended(base) # :nodoc: 6 | base.attr_encrypted_options[:encode] = true 7 | end 8 | end 9 | end 10 | end 11 | 12 | Sequel::Model.extend AttrEncrypted 13 | Sequel::Model.extend AttrEncrypted::Adapters::Sequel 14 | end -------------------------------------------------------------------------------- /lib/attr_encrypted/version.rb: -------------------------------------------------------------------------------- 1 | module AttrEncrypted 2 | # Contains information about this gem's version 3 | module Version 4 | MAJOR = 3 5 | MINOR = 0 6 | PATCH = 3 7 | 8 | # Returns a version string by joining MAJOR, MINOR, and PATCH with '.' 9 | # 10 | # Example 11 | # 12 | # Version.string # '1.0.2' 13 | def self.string 14 | [MAJOR, MINOR, PATCH].join('.') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require 'rdoc/task' 3 | require "bundler/gem_tasks" 4 | 5 | desc 'Test the attr_encrypted gem.' 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << 'lib' 8 | t.pattern = 'test/**/*_test.rb' 9 | t.warning = false 10 | t.verbose = true 11 | end 12 | 13 | desc 'Generate documentation for the attr_encrypted gem.' 14 | Rake::RDocTask.new(:rdoc) do |rdoc| 15 | rdoc.rdoc_dir = 'rdoc' 16 | rdoc.title = 'attr_encrypted' 17 | rdoc.options << '--line-numbers' << '--inline-source' 18 | rdoc.rdoc_files.include('README*') 19 | rdoc.rdoc_files.include('lib/**/*.rb') 20 | end 21 | 22 | desc 'Default: run unit tests.' 23 | task :default => :test 24 | -------------------------------------------------------------------------------- /lib/attr_encrypted/adapters/data_mapper.rb: -------------------------------------------------------------------------------- 1 | if defined?(DataMapper) 2 | module AttrEncrypted 3 | module Adapters 4 | module DataMapper 5 | def self.extended(base) # :nodoc: 6 | class << base 7 | alias_method :included_without_attr_encrypted, :included 8 | alias_method :included, :included_with_attr_encrypted 9 | end 10 | end 11 | 12 | def included_with_attr_encrypted(base) 13 | included_without_attr_encrypted(base) 14 | base.extend AttrEncrypted 15 | base.attr_encrypted_options[:encode] = true 16 | end 17 | end 18 | end 19 | end 20 | 21 | DataMapper::Resource.extend AttrEncrypted::Adapters::DataMapper 22 | end -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Sean Huber - shuber@huberry.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.0 6 | - 2.1 7 | - 2.2.2 8 | - 2.3.0 9 | - 2.4.0 10 | - rbx 11 | env: 12 | - ACTIVERECORD=3.0.0 13 | - ACTIVERECORD=3.1.0 14 | - ACTIVERECORD=3.2.0 15 | - ACTIVERECORD=4.0.0 16 | - ACTIVERECORD=4.1.0 17 | - ACTIVERECORD=4.2.0 18 | - ACTIVERECORD=5.0.0 19 | - ACTIVERECORD=5.1.1 20 | matrix: 21 | exclude: 22 | - rvm: 2.0 23 | env: ACTIVERECORD=5.0.0 24 | - rvm: 2.0 25 | env: ACTIVERECORD=5.1.1 26 | - rvm: 2.1 27 | env: ACTIVERECORD=5.0.0 28 | - rvm: 2.1 29 | env: ACTIVERECORD=5.1.1 30 | - rvm: 2.4.0 31 | env: ACTIVERECORD=3.0.0 32 | - rvm: 2.4.0 33 | env: ACTIVERECORD=3.1.0 34 | - rvm: 2.4.0 35 | env: ACTIVERECORD=3.2.0 36 | - rvm: 2.4.0 37 | env: ACTIVERECORD=4.0.0 38 | - rvm: 2.4.0 39 | env: ACTIVERECORD=4.1.0 40 | - rvm: rbx 41 | env: ACTIVERECORD=5.0.0 42 | - rvm: rbx 43 | env: ACTIVERECORD=5.1.1 44 | allow_failures: 45 | - rvm: rbx 46 | fast_finish: true 47 | addons: 48 | code_climate: 49 | repo_token: a90435ed4954dd6e9f3697a20c5bc3754f67d94703f870e8fc7b00f69f5b2d06 50 | -------------------------------------------------------------------------------- /certs/saghaulor.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdDCCAlygAwIBAgIBATANBgkqhkiG9w0BAQUFADBAMRIwEAYDVQQDDAlzYWdo 3 | YXVsb3IxFTATBgoJkiaJk/IsZAEZFgVnbWFpbDETMBEGCgmSJomT8ixkARkWA2Nv 4 | bTAeFw0xNjAxMTEyMjQyMDFaFw0xNzAxMTAyMjQyMDFaMEAxEjAQBgNVBAMMCXNh 5 | Z2hhdWxvcjEVMBMGCgmSJomT8ixkARkWBWdtYWlsMRMwEQYKCZImiZPyLGQBGRYD 6 | Y29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0xdQYk2GwCpQ1n/ 7 | n2mPVYHLYqU5TAn/82t5kbqBUWjbcj8tHAi41tJ19+fT/hH0dog8JHvho1zmOr71 8 | ZIqreJQo60TqP6oE9a5HncUpjqbRp7tOmHo9E+mOW1yT4NiXqFf1YINExQKy2XND 9 | WPQ+T50ZNUsGMfHFWB4NAymejRWXlOEY3bvKW0UHFeNmouP5he51TjoP8uCc9536 10 | 4AIWVP/zzzjwrFtC7av7nRw4Y+gX2bQjrkK2k2JS0ejiGzKBIEMJejcI2B+t79zT 11 | kUQq9SFwp2BrKSIy+4kh4CiF20RT/Hfc1MbvTxSIl/bbIxCYEOhmtHExHi0CoCWs 12 | YCGCXQIDAQABo3kwdzAJBgNVHRMEAjAAMAsGA1UdDwQEAwIEsDAdBgNVHQ4EFgQU 13 | SCpVzSBvYbO6B3oT3n3RCZmurG8wHgYDVR0RBBcwFYETc2FnaGF1bG9yQGdtYWls 14 | LmNvbTAeBgNVHRIEFzAVgRNzYWdoYXVsb3JAZ21haWwuY29tMA0GCSqGSIb3DQEB 15 | BQUAA4IBAQAeiGdC3e0WiZpm0cF/b7JC6hJYXC9Yv9VsRAWD9ROsLjFKwOhmonnc 16 | +l/QrmoTjMakYXBCai/Ca3L+k5eRrKilgyITILsmmFxK8sqPJXUw2Jmwk/dAky6x 17 | hHKVZAofT1OrOOPJ2USoZyhR/VI8epLaD5wUmkVDNqtZWviW+dtRa55aPYjRw5Pj 18 | wuj9nybhZr+BbEbmZE//2nbfkM4hCuMtxxxilPrJ22aYNmeWU0wsPpDyhPYxOUgU 19 | ZjeLmnSDiwL6doiP5IiwALH/dcHU67ck3NGf6XyqNwQrrmtPY0mv1WVVL4Uh+vYE 20 | kHoFzE2no0BfBg78Re8fY69P5yES5ncC 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test/sequel_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | DB.create_table :humans do 4 | primary_key :id 5 | column :encrypted_email, :string 6 | column :encrypted_email_salt, String 7 | column :encrypted_email_iv, :string 8 | column :password, :string 9 | column :encrypted_credentials, :string 10 | column :encrypted_credentials_iv, :string 11 | column :encrypted_credentials_salt, String 12 | end 13 | 14 | class Human < Sequel::Model(:humans) 15 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 16 | 17 | attr_encrypted :email, :key => SECRET_KEY 18 | attr_encrypted :credentials, :key => SECRET_KEY, :marshal => true 19 | 20 | def after_initialize(attrs = {}) 21 | self.credentials ||= { :username => 'example', :password => 'test' } 22 | end 23 | end 24 | 25 | class SequelTest < Minitest::Test 26 | 27 | def setup 28 | Human.all.each(&:destroy) 29 | end 30 | 31 | def test_should_encrypt_email 32 | @human = Human.new :email => 'test@example.com' 33 | assert @human.save 34 | refute_nil @human.encrypted_email 35 | refute_equal @human.email, @human.encrypted_email 36 | assert_equal @human.email, Human.first.email 37 | end 38 | 39 | def test_should_marshal_and_encrypt_credentials 40 | 41 | @human = Human.new :credentials => { :username => 'example', :password => 'test' } 42 | assert @human.save 43 | refute_nil @human.encrypted_credentials 44 | refute_equal @human.credentials, @human.encrypted_credentials 45 | assert_equal @human.credentials, Human.first.credentials 46 | assert Human.first.credentials.is_a?(Hash) 47 | end 48 | 49 | def test_should_encode_by_default 50 | assert Human.attr_encrypted_options[:encode] 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /test/data_mapper_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | DataMapper.setup(:default, 'sqlite3::memory:') 4 | 5 | class Client 6 | include DataMapper::Resource 7 | 8 | property :id, Serial 9 | property :encrypted_email, String 10 | property :encrypted_email_iv, String 11 | property :encrypted_email_salt, String 12 | 13 | property :encrypted_credentials, Text 14 | property :encrypted_credentials_iv, Text 15 | property :encrypted_credentials_salt, Text 16 | 17 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 18 | 19 | attr_encrypted :email, :key => SECRET_KEY 20 | attr_encrypted :credentials, :key => SECRET_KEY, :marshal => true 21 | 22 | def initialize(attrs = {}) 23 | super attrs 24 | self.credentials ||= { :username => 'example', :password => 'test' } 25 | end 26 | end 27 | 28 | DataMapper.auto_migrate! 29 | 30 | class DataMapperTest < Minitest::Test 31 | 32 | def setup 33 | Client.all.each(&:destroy) 34 | end 35 | 36 | def test_should_encrypt_email 37 | @client = Client.new :email => 'test@example.com' 38 | assert @client.save 39 | refute_nil @client.encrypted_email 40 | refute_equal @client.email, @client.encrypted_email 41 | assert_equal @client.email, Client.first.email 42 | end 43 | 44 | def test_should_marshal_and_encrypt_credentials 45 | @client = Client.new 46 | assert @client.save 47 | refute_nil @client.encrypted_credentials 48 | refute_equal @client.credentials, @client.encrypted_credentials 49 | assert_equal @client.credentials, Client.first.credentials 50 | assert Client.first.credentials.is_a?(Hash) 51 | end 52 | 53 | def test_should_encode_by_default 54 | assert Client.attr_encrypted_options[:encode] 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'simplecov-rcov' 3 | require "codeclimate-test-reporter" 4 | 5 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 6 | [ 7 | SimpleCov::Formatter::HTMLFormatter, 8 | SimpleCov::Formatter::RcovFormatter, 9 | CodeClimate::TestReporter::Formatter 10 | ] 11 | ) 12 | 13 | SimpleCov.start do 14 | add_filter 'test' 15 | end 16 | 17 | CodeClimate::TestReporter.start 18 | 19 | require 'minitest/autorun' 20 | 21 | # Rails 4.0.x pins to an old minitest 22 | unless defined?(MiniTest::Test) 23 | MiniTest::Test = MiniTest::Unit::TestCase 24 | end 25 | 26 | require 'active_record' 27 | require 'data_mapper' 28 | require 'digest/sha2' 29 | require 'sequel' 30 | ActiveSupport::Deprecation.behavior = :raise 31 | 32 | $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 33 | $:.unshift(File.dirname(__FILE__)) 34 | require 'attr_encrypted' 35 | 36 | DB = if defined?(RUBY_ENGINE) && RUBY_ENGINE.to_sym == :jruby 37 | Sequel.jdbc('jdbc:sqlite::memory:') 38 | else 39 | Sequel.sqlite 40 | end 41 | 42 | # The :after_initialize hook was removed in Sequel 4.0 43 | # and had been deprecated for a while before that: 44 | # http://sequel.rubyforge.org/rdoc-plugins/classes/Sequel/Plugins/AfterInitialize.html 45 | # This plugin re-enables it. 46 | Sequel::Model.plugin :after_initialize 47 | 48 | SECRET_KEY = SecureRandom.random_bytes(32) 49 | 50 | def base64_encoding_regex 51 | /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$/ 52 | end 53 | 54 | def drop_all_tables 55 | connection = ActiveRecord::Base.connection 56 | tables = (ActiveRecord::VERSION::MAJOR >= 5 ? connection.data_sources : connection.tables) 57 | tables.each { |table| ActiveRecord::Base.connection.drop_table(table) } 58 | end 59 | -------------------------------------------------------------------------------- /test/legacy_sequel_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | DB.create_table :legacy_humans do 4 | primary_key :id 5 | column :encrypted_email, :string 6 | column :password, :string 7 | column :encrypted_credentials, :string 8 | column :salt, :string 9 | end 10 | 11 | class LegacyHuman < Sequel::Model(:legacy_humans) 12 | self.attr_encrypted_options[:insecure_mode] = true 13 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 14 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 15 | 16 | attr_encrypted :email, :key => 'a secret key', mode: :single_iv_and_salt 17 | attr_encrypted :credentials, :key => Proc.new { |human| Encryptor.encrypt(:value => human.salt, :key => 'some private key', insecure_mode: true, algorithm: 'aes-256-cbc') }, :marshal => true, mode: :single_iv_and_salt 18 | 19 | def after_initialize(attrs = {}) 20 | self.salt ||= Digest::SHA1.hexdigest((Time.now.to_i * rand(5)).to_s) 21 | self.credentials ||= { :username => 'example', :password => 'test' } 22 | end 23 | end 24 | 25 | class LegacySequelTest < Minitest::Test 26 | 27 | def setup 28 | LegacyHuman.all.each(&:destroy) 29 | end 30 | 31 | def test_should_encrypt_email 32 | @human = LegacyHuman.new :email => 'test@example.com' 33 | assert @human.save 34 | refute_nil @human.encrypted_email 35 | refute_equal @human.email, @human.encrypted_email 36 | assert_equal @human.email, LegacyHuman.first.email 37 | end 38 | 39 | def test_should_marshal_and_encrypt_credentials 40 | @human = LegacyHuman.new 41 | assert @human.save 42 | refute_nil @human.encrypted_credentials 43 | refute_equal @human.credentials, @human.encrypted_credentials 44 | assert_equal @human.credentials, LegacyHuman.first.credentials 45 | assert LegacyHuman.first.credentials.is_a?(Hash) 46 | end 47 | 48 | def test_should_encode_by_default 49 | assert LegacyHuman.attr_encrypted_options[:encode] 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /test/legacy_data_mapper_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | DataMapper.setup(:default, 'sqlite3::memory:') 4 | 5 | class LegacyClient 6 | include DataMapper::Resource 7 | self.attr_encrypted_options[:insecure_mode] = true 8 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 9 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 10 | 11 | property :id, Serial 12 | property :encrypted_email, String 13 | property :encrypted_credentials, Text 14 | property :salt, String 15 | 16 | attr_encrypted :email, :key => 'a secret key', mode: :single_iv_and_salt 17 | attr_encrypted :credentials, :key => Proc.new { |client| Encryptor.encrypt(:value => client.salt, :key => 'some private key', insecure_mode: true, algorithm: 'aes-256-cbc') }, :marshal => true, mode: :single_iv_and_salt 18 | 19 | def initialize(attrs = {}) 20 | super attrs 21 | self.salt ||= Digest::SHA1.hexdigest((Time.now.to_i * rand(5)).to_s) 22 | self.credentials ||= { :username => 'example', :password => 'test' } 23 | end 24 | end 25 | 26 | DataMapper.auto_migrate! 27 | 28 | class LegacyDataMapperTest < Minitest::Test 29 | 30 | def setup 31 | LegacyClient.all.each(&:destroy) 32 | end 33 | 34 | def test_should_encrypt_email 35 | @client = LegacyClient.new :email => 'test@example.com' 36 | assert @client.save 37 | refute_nil @client.encrypted_email 38 | refute_equal @client.email, @client.encrypted_email 39 | assert_equal @client.email, LegacyClient.first.email 40 | end 41 | 42 | def test_should_marshal_and_encrypt_credentials 43 | @client = LegacyClient.new 44 | assert @client.save 45 | refute_nil @client.encrypted_credentials 46 | refute_equal @client.credentials, @client.encrypted_credentials 47 | assert_equal @client.credentials, LegacyClient.first.credentials 48 | assert LegacyClient.first.credentials.is_a?(Hash) 49 | end 50 | 51 | def test_should_encode_by_default 52 | assert LegacyClient.attr_encrypted_options[:encode] 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /attr_encrypted.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | lib = File.expand_path('../lib/', __FILE__) 4 | $:.unshift lib unless $:.include?(lib) 5 | 6 | require 'attr_encrypted/version' 7 | require 'date' 8 | 9 | Gem::Specification.new do |s| 10 | s.name = 'attr_encrypted' 11 | s.version = AttrEncrypted::Version.string 12 | s.date = Date.today 13 | 14 | s.summary = 'Encrypt and decrypt attributes' 15 | s.description = 'Generates attr_accessors that encrypt and decrypt attributes transparently' 16 | 17 | s.authors = ['Sean Huber', 'S. Brent Faulkner', 'William Monk', 'Stephen Aghaulor'] 18 | s.email = ['seah@shuber.io', 'sbfaulkner@gmail.com', 'billy.monk@gmail.com', 'saghaulor@gmail.com'] 19 | s.homepage = 'http://github.com/attr-encrypted/attr_encrypted' 20 | s.license = 'MIT' 21 | 22 | s.has_rdoc = false 23 | s.rdoc_options = ['--line-numbers', '--inline-source', '--main', 'README.rdoc'] 24 | 25 | s.require_paths = ['lib'] 26 | 27 | s.files = `git ls-files`.split("\n") 28 | s.test_files = `git ls-files -- test/*`.split("\n") 29 | 30 | s.required_ruby_version = '>= 2.0.0' 31 | 32 | s.add_dependency('encryptor', ['~> 3.0.0']) 33 | # support for testing with specific active record version 34 | activerecord_version = if ENV.key?('ACTIVERECORD') 35 | "~> #{ENV['ACTIVERECORD']}" 36 | else 37 | '>= 2.0.0' 38 | end 39 | s.add_development_dependency('activerecord', activerecord_version) 40 | s.add_development_dependency('actionpack', activerecord_version) 41 | s.add_development_dependency('datamapper') 42 | s.add_development_dependency('rake') 43 | s.add_development_dependency('minitest') 44 | s.add_development_dependency('sequel') 45 | if RUBY_VERSION < '2.1.0' 46 | s.add_development_dependency('nokogiri', '< 1.7.0') 47 | end 48 | if defined?(RUBY_ENGINE) && RUBY_ENGINE.to_sym == :jruby 49 | s.add_development_dependency('activerecord-jdbcsqlite3-adapter') 50 | s.add_development_dependency('jdbc-sqlite3', '< 3.8.7') # 3.8.7 is nice and broke 51 | else 52 | s.add_development_dependency('sqlite3') 53 | end 54 | s.add_development_dependency('dm-sqlite-adapter') 55 | s.add_development_dependency('simplecov') 56 | s.add_development_dependency('simplecov-rcov') 57 | s.add_development_dependency("codeclimate-test-reporter", '<= 0.6.0') 58 | 59 | s.cert_chain = ['certs/saghaulor.pem'] 60 | s.signing_key = File.expand_path("~/.ssh/gem-private_key.pem") if $0 =~ /gem\z/ 61 | 62 | s.post_install_message = "\n\n\nWARNING: Several insecure default options and features were deprecated in attr_encrypted v2.0.0.\n 63 | Additionally, there was a bug in Encryptor v2.0.0 that insecurely encrypted data when using an AES-*-GCM algorithm.\n 64 | This bug was fixed but introduced breaking changes between v2.x and v3.x.\n 65 | Please see the README for more information regarding upgrading to attr_encrypted v3.0.0.\n\n\n" 66 | 67 | end 68 | -------------------------------------------------------------------------------- /test/legacy_compatibility_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require_relative 'test_helper' 3 | 4 | # Test to ensure that existing representations in database do not break on 5 | # migrating to new versions of this gem. This ensures that future versions of 6 | # this gem will retain backwards compatibility with data generated by earlier 7 | # versions. 8 | class LegacyCompatibilityTest < Minitest::Test 9 | class LegacyNonmarshallingPet < ActiveRecord::Base 10 | PET_NICKNAME_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-nickname-salt') 11 | PET_NICKNAME_KEY = 'my-really-really-secret-pet-nickname-key' 12 | PET_BIRTHDATE_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-birthdate-salt') 13 | PET_BIRTHDATE_KEY = 'my-really-really-secret-pet-birthdate-key' 14 | 15 | self.attr_encrypted_options[:insecure_mode] = true 16 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 17 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 18 | 19 | attr_encrypted :nickname, 20 | :key => proc { Encryptor.encrypt(:value => PET_NICKNAME_SALT, :key => PET_NICKNAME_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') } 21 | attr_encrypted :birthdate, 22 | :key => proc { Encryptor.encrypt(:value => PET_BIRTHDATE_SALT, :key => PET_BIRTHDATE_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') } 23 | end 24 | 25 | class LegacyMarshallingPet < ActiveRecord::Base 26 | PET_NICKNAME_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-nickname-salt') 27 | PET_NICKNAME_KEY = 'my-really-really-secret-pet-nickname-key' 28 | PET_BIRTHDATE_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-birthdate-salt') 29 | PET_BIRTHDATE_KEY = 'my-really-really-secret-pet-birthdate-key' 30 | 31 | self.attr_encrypted_options[:insecure_mode] = true 32 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 33 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 34 | 35 | attr_encrypted :nickname, 36 | :key => proc { Encryptor.encrypt(:value => PET_NICKNAME_SALT, :key => PET_NICKNAME_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') }, 37 | :marshal => true 38 | attr_encrypted :birthdate, 39 | :key => proc { Encryptor.encrypt(:value => PET_BIRTHDATE_SALT, :key => PET_BIRTHDATE_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') }, 40 | :marshal => true 41 | end 42 | 43 | def setup 44 | drop_all_tables 45 | create_tables 46 | end 47 | 48 | def test_nonmarshalling_backwards_compatibility 49 | pet = LegacyNonmarshallingPet.create!( 50 | :name => 'Fido', 51 | :encrypted_nickname => 'uSUB6KGzta87yxesyVc3DA==', 52 | :encrypted_birthdate => 'I3d691B2PtFXLx15kO067g==' 53 | ) 54 | 55 | assert_equal 'Fido', pet.name 56 | assert_equal 'Fido the Dog', pet.nickname 57 | assert_equal '2011-07-09', pet.birthdate 58 | end 59 | 60 | def test_marshalling_backwards_compatibility 61 | pet = LegacyMarshallingPet.create!( 62 | :name => 'Fido', 63 | :encrypted_nickname => '7RwoT64in4H+fGVBPYtRcN0K4RtriIy1EP4nDojUa8g=', 64 | :encrypted_birthdate => 'bSp9sJhXQSp2QlNZHiujtcK4lRVBE8HQhn1y7moQ63bGJR20hvRSZ73ePAmm+wc5' 65 | ) 66 | 67 | assert_equal 'Fido', pet.name 68 | assert_equal 'Mummy\'s little helper', pet.nickname 69 | 70 | assert_equal Date.new(2011, 7, 9), pet.birthdate 71 | end 72 | 73 | private 74 | 75 | def create_tables 76 | ActiveRecord::Schema.define(:version => 1) do 77 | create_table :legacy_nonmarshalling_pets do |t| 78 | t.string :name 79 | t.string :encrypted_nickname 80 | t.string :encrypted_birthdate 81 | t.string :salt 82 | end 83 | create_table :legacy_marshalling_pets do |t| 84 | t.string :name 85 | t.string :encrypted_nickname 86 | t.string :encrypted_birthdate 87 | t.string :salt 88 | end 89 | end 90 | end 91 | end 92 | 93 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => ':memory:' 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # attr_encrypted # 2 | 3 | ## 3.0.3 ## 4 | * Fixed: attr_was would decrypt the attribute upon every call. This is inefficient and introduces problems when the options change between decrypting an old value and encrypting a new value; for example, when rotating the encryption key. As such, the new approach caches the decrypted value of the old encrypted value such that the old options are no longer needed. (@johnny-lai) (@saghaulor) 5 | 6 | ## 3.0.2 ## 7 | * Changed: Removed alias_method_chain for compatibility with Rails v5.x (@grosser) 8 | * Changed: Updated Travis build matrix to include Rails 5. (@saghaulor) (@connorshea) 9 | * Changed: Removed `.silence_stream` from tests as it has been removed from Rails 5. (@sblackstone) 10 | 11 | ## 3.0.1 ## 12 | * Fixed: attr_was method no longer calls undefined methods. (@saghaulor) 13 | 14 | ## 3.0.0 ## 15 | * Changed: Updated gemspec to use Encryptor v3.0.0. (@saghaulor) 16 | * Changed: Updated README with instructions related to moving from v2.0.0 to v3.0.0. (@saghaulor) 17 | * Fixed: ActiveModel::Dirty methods in the ActiveRecord adapter. (@saghaulor) 18 | 19 | ## 2.0.0 ## 20 | * Added: Now using Encryptor v2.0.0 (@saghaulor) 21 | * Added: Options are copied to the instance. (@saghaulor) 22 | * Added: Operation option is set during encryption/decryption to allow options to be evaluated in the context of the current operation. (@saghaulor) 23 | * Added: IV and salt can be conditionally encoded. (@saghaulor) 24 | * Added: Changelog! (@saghaulor) 25 | * Changed: attr_encrypted no longer extends object, to use with PORO extend your class, all supported ORMs are already extended. (@saghaulor) 26 | * Changed: Salt is now generated with more entropy. (@saghaulor) 27 | * Changed: The default algorithm is now `aes-256-gcm`. (@saghaulor) 28 | * Changed: The default mode is now `:per_attribute_iv`' (@saghaulor) 29 | * Changed: Extracted class level default options hash to a private method. (@saghaulor) 30 | * Changed: Dynamic finders only work with `:single_iv_and_salt` mode. (@saghaulor) 31 | * Changed: Updated documentation to include v2.0.0 changes and 'things to consider' section. (@saghaulor) 32 | * Fixed: All options are evaluated correctly. (@saghaulor) 33 | * Fixed: IV is generated for every encryption operation. (@saghaulor) 34 | * Deprecated: `:single_iv_and_salt` and `:per_attribute_iv_and_salt` modes are deprecated and will be removed in the next major release. (@saghaulor) 35 | * Deprecated: Dynamic finders via `method_missing` is deprecated and will be removed in the next major release. (@saghaulor) 36 | * Removed: Support for Ruby < 2.x (@saghaulor) 37 | * Removed: Support for Rails < 3.x (@saghaulor) 38 | * Removed: Unnecessary use of `alias_method` from ActiveRecord adapter. (@saghaulor) 39 | 40 | ## 1.4.0 ## 41 | * Added: ActiveModel::Dirty#attribute_was (@saghaulor) 42 | * Added: ActiveModel::Dirty#attribute_changed? (@mwean) 43 | 44 | ## 1.3.5 ## 45 | * Changed: Fixed gemspec to explicitly depend on Encryptor v1.3.0 (@saghaulor) 46 | * Fixed: Evaluate `:mode` option as a symbol or proc. (@cheynewallace) 47 | 48 | ## 1.3.4 ## 49 | * Added: ActiveRecord::Base.reload support. (@rcook) 50 | * Fixed: ActiveRecord adapter no longer forces attribute hashes to be string-keyed. (@tamird) 51 | * Fixed: Mass assignment protection in ActiveRecord 4. (@tamird) 52 | * Changed: Now using rubygems over https. (@tamird) 53 | * Changed: Let ActiveRecord define attribute methods. (@saghaulor) 54 | 55 | ## 1.3.3 ## 56 | * Added: Alias attr_encryptor and attr_encrpted. (@Billy Monk) 57 | 58 | ## 1.3.2 ## 59 | * Fixed: Bug regarding strong parameters. (@S. Brent Faulkner) 60 | * Fixed: Bug regarding loading per instance IV and salt. (@S. Brent Faulkner) 61 | * Fixed: Bug regarding assigning nil. (@S. Brent Faulkner) 62 | * Added: Support for protected attributes. (@S. Brent Faulkner) 63 | * Added: Support for ActiveRecord 4. (@S. Brent Faulkner) 64 | 65 | ## 1.3.1 ## 66 | * Added: Support for Rails 2.3.x and 3.1.x. (@S. Brent Faulkner) 67 | 68 | ## 1.3.0 ## 69 | * Fixed: Serialization bug. (@Billy Monk) 70 | * Added: Support for :per_attribute_iv_and_salt mode. (@rcook) 71 | * Fixed: Added dependencies to gemspec. (@jmazzi) 72 | 73 | ## 1.2.1 ## 74 | * Added: Force encoding when not marshaling. (@mosaicxm) 75 | * Fixed: Issue specifying multiple attributes on the same line. (@austintaylor) 76 | * Added: Typecasting to String before encryption (@shuber) 77 | * Added: `"#{attribute}?"` method. (@shuber) 78 | 79 | ## 1.2.0 ## 80 | * Changed: General code refactoring (@shuber) 81 | 82 | ## 1.1.2 ## 83 | * No significant changes 84 | 85 | ## 1.1.1 ## 86 | * Changled: Updated README. (@shuber) 87 | * Added: `before_type_cast` alias to ActiveRecord adapter. (@shuber) 88 | -------------------------------------------------------------------------------- /test/legacy_active_record_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require_relative 'test_helper' 3 | 4 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => ':memory:' 5 | 6 | def create_people_table 7 | ActiveRecord::Schema.define(:version => 1) do 8 | create_table :legacy_people do |t| 9 | t.string :encrypted_email 10 | t.string :password 11 | t.string :encrypted_credentials 12 | t.string :salt 13 | end 14 | end 15 | end 16 | 17 | # The table needs to exist before defining the class 18 | create_people_table 19 | 20 | ActiveRecord::MissingAttributeError = ActiveModel::MissingAttributeError unless defined?(ActiveRecord::MissingAttributeError) 21 | 22 | class LegacyPerson < ActiveRecord::Base 23 | self.attr_encrypted_options[:insecure_mode] = true 24 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 25 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 26 | 27 | attr_encrypted :email, :key => 'a secret key' 28 | attr_encrypted :credentials, :key => Proc.new { |user| Encryptor.encrypt(:value => user.salt, :key => 'some private key', insecure_mode: true, algorithm: 'aes-256-cbc') }, :marshal => true 29 | 30 | ActiveSupport::Deprecation.silenced = true 31 | def after_initialize; end 32 | ActiveSupport::Deprecation.silenced = false 33 | 34 | after_initialize :initialize_salt_and_credentials 35 | 36 | protected 37 | 38 | def initialize_salt_and_credentials 39 | self.salt ||= Digest::SHA256.hexdigest((Time.now.to_i * rand(5)).to_s) 40 | self.credentials ||= { :username => 'example', :password => 'test' } 41 | rescue ActiveRecord::MissingAttributeError 42 | end 43 | end 44 | 45 | class LegacyPersonWithValidation < LegacyPerson 46 | validates_presence_of :email 47 | validates_uniqueness_of :encrypted_email 48 | end 49 | 50 | class LegacyActiveRecordTest < Minitest::Test 51 | 52 | def setup 53 | drop_all_tables 54 | create_people_table 55 | end 56 | 57 | def test_should_decrypt_with_correct_encoding 58 | if defined?(Encoding) 59 | @person = LegacyPerson.create :email => 'test@example.com' 60 | assert_equal 'UTF-8', LegacyPerson.first.email.encoding.name 61 | end 62 | end 63 | 64 | def test_should_encrypt_email 65 | @person = LegacyPerson.create :email => 'test@example.com' 66 | refute_nil @person.encrypted_email 67 | refute_equal @person.email, @person.encrypted_email 68 | assert_equal @person.email, LegacyPerson.first.email 69 | end 70 | 71 | def test_should_marshal_and_encrypt_credentials 72 | @person = LegacyPerson.create 73 | refute_nil @person.encrypted_credentials 74 | refute_equal @person.credentials, @person.encrypted_credentials 75 | assert_equal @person.credentials, LegacyPerson.first.credentials 76 | end 77 | 78 | def test_should_find_by_email 79 | @person = LegacyPerson.create(:email => 'test@example.com') 80 | assert_equal @person, LegacyPerson.find_by_email('test@example.com') 81 | end 82 | 83 | def test_should_find_by_email_and_password 84 | LegacyPerson.create(:email => 'test@example.com', :password => 'invalid') 85 | @person = LegacyPerson.create(:email => 'test@example.com', :password => 'test') 86 | assert_equal @person, LegacyPerson.find_by_email_and_password('test@example.com', 'test') 87 | end 88 | 89 | def test_should_scope_by_email 90 | @person = LegacyPerson.create(:email => 'test@example.com') 91 | assert_equal @person, LegacyPerson.scoped_by_email('test@example.com').first rescue NoMethodError 92 | end 93 | 94 | def test_should_scope_by_email_and_password 95 | LegacyPerson.create(:email => 'test@example.com', :password => 'invalid') 96 | @person = LegacyPerson.create(:email => 'test@example.com', :password => 'test') 97 | assert_equal @person, LegacyPerson.scoped_by_email_and_password('test@example.com', 'test').first rescue NoMethodError 98 | end 99 | 100 | def test_should_encode_by_default 101 | assert LegacyPerson.attr_encrypted_options[:encode] 102 | end 103 | 104 | def test_should_validate_presence_of_email 105 | @person = LegacyPersonWithValidation.new 106 | assert !@person.valid? 107 | assert !@person.errors[:email].empty? || @person.errors.on(:email) 108 | end 109 | 110 | def test_should_validate_uniqueness_of_email 111 | @person = LegacyPersonWithValidation.new :email => 'test@example.com' 112 | assert @person.save 113 | @person2 = LegacyPersonWithValidation.new :email => @person.email 114 | assert !@person2.valid? 115 | assert !@person2.errors[:encrypted_email].empty? || @person2.errors.on(:encrypted_email) 116 | end 117 | 118 | end 119 | -------------------------------------------------------------------------------- /test/compatibility_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require_relative 'test_helper' 3 | 4 | # Test to ensure that existing representations in database do not break on 5 | # migrating to new versions of this gem. This ensures that future versions of 6 | # this gem will retain backwards compatibility with data generated by earlier 7 | # versions. 8 | class CompatibilityTest < Minitest::Test 9 | class NonmarshallingPet < ActiveRecord::Base 10 | PET_NICKNAME_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-nickname-salt') 11 | PET_NICKNAME_KEY = 'my-really-really-secret-pet-nickname-key' 12 | PET_BIRTHDATE_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-birthdate-salt') 13 | PET_BIRTHDATE_KEY = 'my-really-really-secret-pet-birthdate-key' 14 | 15 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 16 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 17 | self.attr_encrypted_options[:insecure_mode] = true 18 | 19 | attr_encrypted :nickname, 20 | :key => proc { Encryptor.encrypt(:value => PET_NICKNAME_SALT, :key => PET_NICKNAME_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') } 21 | attr_encrypted :birthdate, 22 | :key => proc { Encryptor.encrypt(:value => PET_BIRTHDATE_SALT, :key => PET_BIRTHDATE_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') } 23 | end 24 | 25 | class MarshallingPet < ActiveRecord::Base 26 | PET_NICKNAME_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-nickname-salt') 27 | PET_NICKNAME_KEY = 'my-really-really-secret-pet-nickname-key' 28 | PET_BIRTHDATE_SALT = Digest::SHA256.hexdigest('my-really-really-secret-pet-birthdate-salt') 29 | PET_BIRTHDATE_KEY = 'my-really-really-secret-pet-birthdate-key' 30 | 31 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 32 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 33 | self.attr_encrypted_options[:insecure_mode] = true 34 | 35 | attr_encrypted :nickname, 36 | :key => proc { Encryptor.encrypt(:value => PET_NICKNAME_SALT, :key => PET_NICKNAME_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') }, 37 | :marshal => true 38 | attr_encrypted :birthdate, 39 | :key => proc { Encryptor.encrypt(:value => PET_BIRTHDATE_SALT, :key => PET_BIRTHDATE_KEY, insecure_mode: true, algorithm: 'aes-256-cbc') }, 40 | :marshal => true 41 | end 42 | 43 | def setup 44 | drop_all_tables 45 | create_tables 46 | end 47 | 48 | def test_nonmarshalling_backwards_compatibility 49 | pet = NonmarshallingPet.create!( 50 | :name => 'Fido', 51 | :encrypted_nickname => 'E4lJTxFG/EfkfPg5MpnriQ==', 52 | :encrypted_nickname_iv => 'z4Q8deE4h7f6S8NNZcbPNg==', 53 | :encrypted_nickname_salt => 'adcd833001a873db', 54 | :encrypted_birthdate => '6uKEAiFVdJw+N5El+U6Gow==', 55 | :encrypted_birthdate_iv => 'zxtc1XPssL4s2HwA69nORQ==', 56 | :encrypted_birthdate_salt => '4f879270045eaad7' 57 | ) 58 | 59 | assert_equal 'Fido', pet.name 60 | assert_equal 'Fido the Dog', pet.nickname 61 | assert_equal '2011-07-09', pet.birthdate 62 | end 63 | 64 | def test_marshalling_backwards_compatibility 65 | pet = MarshallingPet.create!( 66 | :name => 'Fido', 67 | :encrypted_nickname => 'EsQScJYkPw80vVGvKWkE37Px99HHpXPFjoEPTNa4rbs=', 68 | :encrypted_nickname_iv => 'fNq1OZcGvty4KfcvGTcFSw==', 69 | :encrypted_nickname_salt => '733b459b7d34c217', 70 | :encrypted_birthdate => '+VUlKQGfNWkOgCwI4hv+3qlGIwh9h6cJ/ranJlaxvU+xxQdL3H3cOzTcI2rkYkdR', 71 | :encrypted_birthdate_iv => 'Ka+zF/SwEYZKwVa24lvFfA==', 72 | :encrypted_birthdate_salt => 'd5e892d5bbd81566' 73 | ) 74 | 75 | assert_equal 'Fido', pet.name 76 | assert_equal 'Mummy\'s little helper', pet.nickname 77 | 78 | assert_equal Date.new(2011, 7, 9), pet.birthdate 79 | end 80 | 81 | private 82 | 83 | def create_tables 84 | ActiveRecord::Schema.define(:version => 1) do 85 | create_table :nonmarshalling_pets do |t| 86 | t.string :name 87 | t.string :encrypted_nickname 88 | t.string :encrypted_nickname_iv 89 | t.string :encrypted_nickname_salt 90 | t.string :encrypted_birthdate 91 | t.string :encrypted_birthdate_iv 92 | t.string :encrypted_birthdate_salt 93 | end 94 | create_table :marshalling_pets do |t| 95 | t.string :name 96 | t.string :encrypted_nickname 97 | t.string :encrypted_nickname_iv 98 | t.string :encrypted_nickname_salt 99 | t.string :encrypted_birthdate 100 | t.string :encrypted_birthdate_iv 101 | t.string :encrypted_birthdate_salt 102 | end 103 | end 104 | end 105 | end 106 | 107 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => ':memory:' 108 | -------------------------------------------------------------------------------- /lib/attr_encrypted/adapters/active_record.rb: -------------------------------------------------------------------------------- 1 | if defined?(ActiveRecord::Base) 2 | module AttrEncrypted 3 | module Adapters 4 | module ActiveRecord 5 | def self.extended(base) # :nodoc: 6 | base.class_eval do 7 | 8 | # https://github.com/attr-encrypted/attr_encrypted/issues/68 9 | alias_method :reload_without_attr_encrypted, :reload 10 | def reload(*args, &block) 11 | result = reload_without_attr_encrypted(*args, &block) 12 | self.class.encrypted_attributes.keys.each do |attribute_name| 13 | instance_variable_set("@#{attribute_name}", nil) 14 | end 15 | result 16 | end 17 | 18 | attr_encrypted_options[:encode] = true 19 | 20 | class << self 21 | alias_method :method_missing_without_attr_encrypted, :method_missing 22 | alias_method :method_missing, :method_missing_with_attr_encrypted 23 | end 24 | 25 | def perform_attribute_assignment(method, new_attributes, *args) 26 | return if new_attributes.blank? 27 | 28 | send method, new_attributes.reject { |k, _| self.class.encrypted_attributes.key?(k.to_sym) }, *args 29 | send method, new_attributes.reject { |k, _| !self.class.encrypted_attributes.key?(k.to_sym) }, *args 30 | end 31 | private :perform_attribute_assignment 32 | 33 | if ::ActiveRecord::VERSION::STRING > "3.1" 34 | alias_method :assign_attributes_without_attr_encrypted, :assign_attributes 35 | def assign_attributes(*args) 36 | perform_attribute_assignment :assign_attributes_without_attr_encrypted, *args 37 | end 38 | end 39 | 40 | alias_method :attributes_without_attr_encrypted=, :attributes= 41 | def attributes=(*args) 42 | perform_attribute_assignment :attributes_without_attr_encrypted=, *args 43 | end 44 | end 45 | end 46 | 47 | protected 48 | 49 | # attr_encrypted method 50 | def attr_encrypted(*attrs) 51 | super 52 | options = attrs.extract_options! 53 | attr = attrs.pop 54 | attribute attr if ::ActiveRecord::VERSION::STRING >= "5.1.0" 55 | options.merge! encrypted_attributes[attr] 56 | 57 | define_method("#{attr}_was") do 58 | attribute_was(attr) 59 | end 60 | 61 | if ::ActiveRecord::VERSION::STRING >= "4.1" 62 | define_method("#{attr}_changed?") do |options = {}| 63 | attribute_changed?(attr, options) 64 | end 65 | else 66 | define_method("#{attr}_changed?") do 67 | attribute_changed?(attr) 68 | end 69 | end 70 | 71 | define_method("#{attr}_change") do 72 | attribute_change(attr) 73 | end 74 | 75 | define_method("#{attr}_with_dirtiness=") do |value| 76 | attribute_will_change!(attr) if value != __send__(attr) 77 | __send__("#{attr}_without_dirtiness=", value) 78 | end 79 | 80 | alias_method "#{attr}_without_dirtiness=", "#{attr}=" 81 | alias_method "#{attr}=", "#{attr}_with_dirtiness=" 82 | 83 | alias_method "#{attr}_before_type_cast", attr 84 | end 85 | 86 | def attribute_instance_methods_as_symbols 87 | # We add accessor methods of the db columns to the list of instance 88 | # methods returned to let ActiveRecord define the accessor methods 89 | # for the db columns 90 | 91 | # Use with_connection so the connection doesn't stay pinned to the thread. 92 | connected = ::ActiveRecord::Base.connection_pool.with_connection(&:active?) rescue false 93 | 94 | if connected && table_exists? 95 | columns_hash.keys.inject(super) {|instance_methods, column_name| instance_methods.concat [column_name.to_sym, :"#{column_name}="]} 96 | else 97 | super 98 | end 99 | end 100 | 101 | # Allows you to use dynamic methods like find_by_email or scoped_by_email for 102 | # encrypted attributes 103 | # 104 | # NOTE: This only works when the :key option is specified as a string (see the README) 105 | # 106 | # This is useful for encrypting fields like email addresses. Your user's email addresses 107 | # are encrypted in the database, but you can still look up a user by email for logging in 108 | # 109 | # Example 110 | # 111 | # class User < ActiveRecord::Base 112 | # attr_encrypted :email, key: 'secret key' 113 | # end 114 | # 115 | # User.find_by_email_and_password('test@example.com', 'testing') 116 | # # results in a call to 117 | # User.find_by_encrypted_email_and_password('the_encrypted_version_of_test@example.com', 'testing') 118 | def method_missing_with_attr_encrypted(method, *args, &block) 119 | if match = /^(find|scoped)_(all_by|by)_([_a-zA-Z]\w*)$/.match(method.to_s) 120 | attribute_names = match.captures.last.split('_and_') 121 | attribute_names.each_with_index do |attribute, index| 122 | if attr_encrypted?(attribute) && encrypted_attributes[attribute.to_sym][:mode] == :single_iv_and_salt 123 | args[index] = send("encrypt_#{attribute}", args[index]) 124 | warn "DEPRECATION WARNING: This feature will be removed in the next major release." 125 | attribute_names[index] = encrypted_attributes[attribute.to_sym][:attribute] 126 | end 127 | end 128 | method = "#{match.captures[0]}_#{match.captures[1]}_#{attribute_names.join('_and_')}".to_sym 129 | end 130 | method_missing_without_attr_encrypted(method, *args, &block) 131 | end 132 | end 133 | end 134 | end 135 | 136 | ActiveRecord::Base.extend AttrEncrypted 137 | ActiveRecord::Base.extend AttrEncrypted::Adapters::ActiveRecord 138 | end 139 | -------------------------------------------------------------------------------- /test/legacy_attr_encrypted_test.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require_relative 'test_helper' 3 | 4 | class LegacySillyEncryptor 5 | def self.silly_encrypt(options) 6 | (options[:value] + options[:some_arg]).reverse 7 | end 8 | 9 | def self.silly_decrypt(options) 10 | options[:value].reverse.gsub(/#{options[:some_arg]}$/, '') 11 | end 12 | end 13 | 14 | class LegacyUser 15 | extend AttrEncrypted 16 | self.attr_encrypted_options[:key] = Proc.new { |user| user.class.to_s } # default key 17 | self.attr_encrypted_options[:insecure_mode] = true 18 | self.attr_encrypted_options[:algorithm] = 'aes-256-cbc' 19 | self.attr_encrypted_options[:mode] = :single_iv_and_salt 20 | 21 | attr_encrypted :email, :without_encoding, :key => 'secret key' 22 | attr_encrypted :password, :prefix => 'crypted_', :suffix => '_test' 23 | attr_encrypted :ssn, :key => :salt, :attribute => 'ssn_encrypted' 24 | attr_encrypted :credit_card, :encryptor => LegacySillyEncryptor, :encrypt_method => :silly_encrypt, :decrypt_method => :silly_decrypt, :some_arg => 'test' 25 | attr_encrypted :with_encoding, :key => 'secret key', :encode => true 26 | attr_encrypted :with_custom_encoding, :key => 'secret key', :encode => 'm' 27 | attr_encrypted :with_marshaling, :key => 'secret key', :marshal => true 28 | attr_encrypted :with_true_if, :key => 'secret key', :if => true 29 | attr_encrypted :with_false_if, :key => 'secret key', :if => false 30 | attr_encrypted :with_true_unless, :key => 'secret key', :unless => true 31 | attr_encrypted :with_false_unless, :key => 'secret key', :unless => false 32 | attr_encrypted :with_if_changed, :key => 'secret key', :if => :should_encrypt 33 | 34 | attr_encryptor :aliased, :key => 'secret_key' 35 | 36 | attr_accessor :salt 37 | attr_accessor :should_encrypt 38 | 39 | def initialize 40 | self.salt = Time.now.to_i.to_s 41 | self.should_encrypt = true 42 | end 43 | end 44 | 45 | class LegacyAdmin < LegacyUser 46 | attr_encrypted :testing 47 | end 48 | 49 | class LegacySomeOtherClass 50 | extend AttrEncrypted 51 | def self.call(object) 52 | object.class 53 | end 54 | end 55 | 56 | class LegacyAttrEncryptedTest < Minitest::Test 57 | 58 | def test_should_store_email_in_encrypted_attributes 59 | assert LegacyUser.encrypted_attributes.include?(:email) 60 | end 61 | 62 | def test_should_not_store_salt_in_encrypted_attributes 63 | assert !LegacyUser.encrypted_attributes.include?(:salt) 64 | end 65 | 66 | def test_attr_encrypted_should_return_true_for_email 67 | assert LegacyUser.attr_encrypted?('email') 68 | end 69 | 70 | def test_attr_encrypted_should_not_use_the_same_attribute_name_for_two_attributes_in_the_same_line 71 | refute_equal LegacyUser.encrypted_attributes[:email][:attribute], LegacyUser.encrypted_attributes[:without_encoding][:attribute] 72 | end 73 | 74 | def test_attr_encrypted_should_return_false_for_salt 75 | assert !LegacyUser.attr_encrypted?('salt') 76 | end 77 | 78 | def test_should_generate_an_encrypted_attribute 79 | assert LegacyUser.new.respond_to?(:encrypted_email) 80 | end 81 | 82 | def test_should_generate_an_encrypted_attribute_with_a_prefix_and_suffix 83 | assert LegacyUser.new.respond_to?(:crypted_password_test) 84 | end 85 | 86 | def test_should_generate_an_encrypted_attribute_with_the_attribute_option 87 | assert LegacyUser.new.respond_to?(:ssn_encrypted) 88 | end 89 | 90 | def test_should_not_encrypt_nil_value 91 | assert_nil LegacyUser.encrypt_email(nil) 92 | end 93 | 94 | def test_should_not_encrypt_empty_string 95 | assert_equal '', LegacyUser.encrypt_email('') 96 | end 97 | 98 | def test_should_encrypt_email 99 | refute_nil LegacyUser.encrypt_email('test@example.com') 100 | refute_equal 'test@example.com', LegacyUser.encrypt_email('test@example.com') 101 | end 102 | 103 | def test_should_encrypt_email_when_modifying_the_attr_writer 104 | @user = LegacyUser.new 105 | assert_nil @user.encrypted_email 106 | @user.email = 'test@example.com' 107 | refute_nil @user.encrypted_email 108 | assert_equal LegacyUser.encrypt_email('test@example.com'), @user.encrypted_email 109 | end 110 | 111 | def test_should_not_decrypt_nil_value 112 | assert_nil LegacyUser.decrypt_email(nil) 113 | end 114 | 115 | def test_should_not_decrypt_empty_string 116 | assert_equal '', LegacyUser.decrypt_email('') 117 | end 118 | 119 | def test_should_decrypt_email 120 | encrypted_email = LegacyUser.encrypt_email('test@example.com') 121 | refute_equal 'test@test.com', encrypted_email 122 | assert_equal 'test@example.com', LegacyUser.decrypt_email(encrypted_email) 123 | end 124 | 125 | def test_should_decrypt_email_when_reading 126 | @user = LegacyUser.new 127 | assert_nil @user.email 128 | @user.encrypted_email = LegacyUser.encrypt_email('test@example.com') 129 | assert_equal 'test@example.com', @user.email 130 | end 131 | 132 | def test_should_encrypt_with_encoding 133 | assert_equal LegacyUser.encrypt_with_encoding('test'), [LegacyUser.encrypt_without_encoding('test')].pack('m') 134 | end 135 | 136 | def test_should_decrypt_with_encoding 137 | encrypted = LegacyUser.encrypt_with_encoding('test') 138 | assert_equal 'test', LegacyUser.decrypt_with_encoding(encrypted) 139 | assert_equal LegacyUser.decrypt_with_encoding(encrypted), LegacyUser.decrypt_without_encoding(encrypted.unpack('m').first) 140 | end 141 | 142 | def test_should_decrypt_utf8_with_encoding 143 | encrypted = LegacyUser.encrypt_with_encoding("test\xC2\xA0utf-8\xC2\xA0text") 144 | assert_equal "test\xC2\xA0utf-8\xC2\xA0text", LegacyUser.decrypt_with_encoding(encrypted) 145 | assert_equal LegacyUser.decrypt_with_encoding(encrypted), LegacyUser.decrypt_without_encoding(encrypted.unpack('m').first) 146 | end 147 | 148 | def test_should_encrypt_with_custom_encoding 149 | assert_equal LegacyUser.encrypt_with_custom_encoding('test'), [LegacyUser.encrypt_without_encoding('test')].pack('m') 150 | end 151 | 152 | def test_should_decrypt_with_custom_encoding 153 | encrypted = LegacyUser.encrypt_with_custom_encoding('test') 154 | assert_equal 'test', LegacyUser.decrypt_with_custom_encoding(encrypted) 155 | assert_equal LegacyUser.decrypt_with_custom_encoding(encrypted), LegacyUser.decrypt_without_encoding(encrypted.unpack('m').first) 156 | end 157 | 158 | def test_should_encrypt_with_marshaling 159 | @user = LegacyUser.new 160 | @user.with_marshaling = [1, 2, 3] 161 | refute_nil @user.encrypted_with_marshaling 162 | assert_equal LegacyUser.encrypt_with_marshaling([1, 2, 3]), @user.encrypted_with_marshaling 163 | end 164 | 165 | def test_should_decrypt_with_marshaling 166 | encrypted = LegacyUser.encrypt_with_marshaling([1, 2, 3]) 167 | @user = LegacyUser.new 168 | assert_nil @user.with_marshaling 169 | @user.encrypted_with_marshaling = encrypted 170 | assert_equal [1, 2, 3], @user.with_marshaling 171 | end 172 | 173 | def test_should_use_custom_encryptor_and_crypt_method_names_and_arguments 174 | assert_equal LegacySillyEncryptor.silly_encrypt(:value => 'testing', :some_arg => 'test'), LegacyUser.encrypt_credit_card('testing') 175 | end 176 | 177 | def test_should_evaluate_a_key_passed_as_a_symbol 178 | @user = LegacyUser.new 179 | assert_nil @user.ssn_encrypted 180 | @user.ssn = 'testing' 181 | refute_nil @user.ssn_encrypted 182 | assert_equal Encryptor.encrypt(:value => 'testing', :key => @user.salt, insecure_mode: true, algorithm: 'aes-256-cbc'), @user.ssn_encrypted 183 | end 184 | 185 | def test_should_evaluate_a_key_passed_as_a_proc 186 | @user = LegacyUser.new 187 | assert_nil @user.crypted_password_test 188 | @user.password = 'testing' 189 | refute_nil @user.crypted_password_test 190 | assert_equal Encryptor.encrypt(:value => 'testing', :key => 'LegacyUser', insecure_mode: true, algorithm: 'aes-256-cbc'), @user.crypted_password_test 191 | end 192 | 193 | def test_should_use_options_found_in_the_attr_encrypted_options_attribute 194 | @user = LegacyUser.new 195 | assert_nil @user.crypted_password_test 196 | @user.password = 'testing' 197 | refute_nil @user.crypted_password_test 198 | assert_equal Encryptor.encrypt(:value => 'testing', :key => 'LegacyUser', insecure_mode: true, algorithm: 'aes-256-cbc'), @user.crypted_password_test 199 | end 200 | 201 | def test_should_inherit_encrypted_attributes 202 | assert_equal [LegacyUser.encrypted_attributes.keys, :testing].flatten.collect { |key| key.to_s }.sort, LegacyAdmin.encrypted_attributes.keys.collect { |key| key.to_s }.sort 203 | end 204 | 205 | def test_should_inherit_attr_encrypted_options 206 | assert !LegacyUser.attr_encrypted_options.empty? 207 | assert_equal LegacyUser.attr_encrypted_options, LegacyAdmin.attr_encrypted_options 208 | end 209 | 210 | def test_should_not_inherit_unrelated_attributes 211 | assert LegacySomeOtherClass.attr_encrypted_options.empty? 212 | assert LegacySomeOtherClass.encrypted_attributes.empty? 213 | end 214 | 215 | def test_should_evaluate_a_symbol_option 216 | assert_equal LegacySomeOtherClass, LegacySomeOtherClass.new.send(:evaluate_attr_encrypted_option, :class) 217 | end 218 | 219 | def test_should_evaluate_a_proc_option 220 | assert_equal LegacySomeOtherClass, LegacySomeOtherClass.new.send(:evaluate_attr_encrypted_option, proc { |object| object.class }) 221 | end 222 | 223 | def test_should_evaluate_a_lambda_option 224 | assert_equal LegacySomeOtherClass, LegacySomeOtherClass.new.send(:evaluate_attr_encrypted_option, lambda { |object| object.class }) 225 | end 226 | 227 | def test_should_evaluate_a_method_option 228 | assert_equal LegacySomeOtherClass, LegacySomeOtherClass.new.send(:evaluate_attr_encrypted_option, LegacySomeOtherClass.method(:call)) 229 | end 230 | 231 | def test_should_return_a_string_option 232 | class_string = 'LegacySomeOtherClass' 233 | assert_equal class_string, LegacySomeOtherClass.new.send(:evaluate_attr_encrypted_option, class_string) 234 | end 235 | 236 | def test_should_encrypt_with_true_if 237 | @user = LegacyUser.new 238 | assert_nil @user.encrypted_with_true_if 239 | @user.with_true_if = 'testing' 240 | refute_nil @user.encrypted_with_true_if 241 | assert_equal Encryptor.encrypt(:value => 'testing', :key => 'secret key', insecure_mode: true, algorithm: 'aes-256-cbc'), @user.encrypted_with_true_if 242 | end 243 | 244 | def test_should_not_encrypt_with_false_if 245 | @user = LegacyUser.new 246 | assert_nil @user.encrypted_with_false_if 247 | @user.with_false_if = 'testing' 248 | refute_nil @user.encrypted_with_false_if 249 | assert_equal 'testing', @user.encrypted_with_false_if 250 | end 251 | 252 | def test_should_encrypt_with_false_unless 253 | @user = LegacyUser.new 254 | assert_nil @user.encrypted_with_false_unless 255 | @user.with_false_unless = 'testing' 256 | refute_nil @user.encrypted_with_false_unless 257 | assert_equal Encryptor.encrypt(:value => 'testing', :key => 'secret key', insecure_mode: true, algorithm: 'aes-256-cbc'), @user.encrypted_with_false_unless 258 | end 259 | 260 | def test_should_not_encrypt_with_true_unless 261 | @user = LegacyUser.new 262 | assert_nil @user.encrypted_with_true_unless 263 | @user.with_true_unless = 'testing' 264 | refute_nil @user.encrypted_with_true_unless 265 | assert_equal 'testing', @user.encrypted_with_true_unless 266 | end 267 | 268 | def test_should_work_with_aliased_attr_encryptor 269 | assert LegacyUser.encrypted_attributes.include?(:aliased) 270 | end 271 | 272 | def test_should_always_reset_options 273 | @user = LegacyUser.new 274 | @user.with_if_changed = "encrypt_stuff" 275 | 276 | @user = LegacyUser.new 277 | @user.should_encrypt = false 278 | @user.with_if_changed = "not_encrypted_stuff" 279 | assert_equal "not_encrypted_stuff", @user.with_if_changed 280 | assert_equal "not_encrypted_stuff", @user.encrypted_with_if_changed 281 | end 282 | 283 | def test_should_cast_values_as_strings_before_encrypting 284 | string_encrypted_email = LegacyUser.encrypt_email('3') 285 | assert_equal string_encrypted_email, LegacyUser.encrypt_email(3) 286 | assert_equal '3', LegacyUser.decrypt_email(string_encrypted_email) 287 | end 288 | 289 | def test_should_create_query_accessor 290 | @user = LegacyUser.new 291 | assert !@user.email? 292 | @user.email = '' 293 | assert !@user.email? 294 | @user.email = 'test@example.com' 295 | assert @user.email? 296 | end 297 | 298 | end 299 | -------------------------------------------------------------------------------- /test/active_record_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 4 | 5 | def create_tables 6 | ActiveRecord::Schema.define(version: 1) do 7 | self.verbose = false 8 | create_table :people do |t| 9 | t.string :encrypted_email 10 | t.string :password 11 | t.string :encrypted_credentials 12 | t.binary :salt 13 | t.binary :key_iv 14 | t.string :encrypted_email_salt 15 | t.string :encrypted_credentials_salt 16 | t.string :encrypted_email_iv 17 | t.string :encrypted_credentials_iv 18 | end 19 | create_table :accounts do |t| 20 | t.string :encrypted_password 21 | t.string :encrypted_password_iv 22 | t.string :encrypted_password_salt 23 | t.string :key 24 | end 25 | create_table :users do |t| 26 | t.string :login 27 | t.string :encrypted_password 28 | t.string :encrypted_password_iv 29 | t.boolean :is_admin 30 | end 31 | create_table :prime_ministers do |t| 32 | t.string :encrypted_name 33 | t.string :encrypted_name_iv 34 | end 35 | create_table :addresses do |t| 36 | t.binary :encrypted_street 37 | t.binary :encrypted_street_iv 38 | t.binary :encrypted_zipcode 39 | t.string :mode 40 | end 41 | end 42 | end 43 | 44 | # The table needs to exist before defining the class 45 | create_tables 46 | 47 | ActiveRecord::MissingAttributeError = ActiveModel::MissingAttributeError unless defined?(ActiveRecord::MissingAttributeError) 48 | 49 | if ::ActiveRecord::VERSION::STRING > "4.0" 50 | module Rack 51 | module Test 52 | class UploadedFile; end 53 | end 54 | end 55 | 56 | require 'action_controller/metal/strong_parameters' 57 | end 58 | 59 | class Person < ActiveRecord::Base 60 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 61 | attr_encrypted :email, key: SECRET_KEY 62 | attr_encrypted :credentials, key: Proc.new { |user| Encryptor.encrypt(value: user.salt, key: SECRET_KEY, iv: user.key_iv) }, marshal: true 63 | 64 | after_initialize :initialize_salt_and_credentials 65 | 66 | protected 67 | 68 | def initialize_salt_and_credentials 69 | self.key_iv ||= SecureRandom.random_bytes(12) 70 | self.salt ||= Digest::SHA256.hexdigest((Time.now.to_i * rand(1000)).to_s)[0..15] 71 | self.credentials ||= { username: 'example', password: 'test' } 72 | end 73 | end 74 | 75 | class PersonWithValidation < Person 76 | validates_presence_of :email 77 | end 78 | 79 | class PersonWithProcMode < Person 80 | attr_encrypted :email, key: SECRET_KEY, mode: Proc.new { :per_attribute_iv_and_salt } 81 | attr_encrypted :credentials, key: SECRET_KEY, mode: Proc.new { :single_iv_and_salt }, insecure_mode: true 82 | end 83 | 84 | class Account < ActiveRecord::Base 85 | ACCOUNT_ENCRYPTION_KEY = SecureRandom.urlsafe_base64(24) 86 | attr_encrypted :password, key: :password_encryption_key 87 | 88 | def encrypting?(attr) 89 | encrypted_attributes[attr][:operation] == :encrypting 90 | end 91 | 92 | def password_encryption_key 93 | if encrypting?(:password) 94 | self.key = ACCOUNT_ENCRYPTION_KEY 95 | else 96 | self.key 97 | end 98 | end 99 | end 100 | 101 | class PersonWithSerialization < ActiveRecord::Base 102 | self.table_name = 'people' 103 | attr_encrypted :email, key: SECRET_KEY 104 | serialize :password 105 | end 106 | 107 | class UserWithProtectedAttribute < ActiveRecord::Base 108 | self.table_name = 'users' 109 | attr_encrypted :password, key: SECRET_KEY 110 | attr_protected :is_admin if ::ActiveRecord::VERSION::STRING < "4.0" 111 | end 112 | 113 | class PersonUsingAlias < ActiveRecord::Base 114 | self.table_name = 'people' 115 | attr_encryptor :email, key: SECRET_KEY 116 | end 117 | 118 | class PrimeMinister < ActiveRecord::Base 119 | attr_encrypted :name, marshal: true, key: SECRET_KEY 120 | end 121 | 122 | class Address < ActiveRecord::Base 123 | self.attr_encrypted_options[:marshal] = false 124 | self.attr_encrypted_options[:encode] = false 125 | attr_encrypted :street, encode_iv: false, key: SECRET_KEY 126 | attr_encrypted :zipcode, key: SECRET_KEY, mode: Proc.new { |address| address.mode.to_sym }, insecure_mode: true 127 | end 128 | 129 | class ActiveRecordTest < Minitest::Test 130 | 131 | def setup 132 | drop_all_tables 133 | create_tables 134 | end 135 | 136 | def test_should_encrypt_email 137 | @person = Person.create(email: 'test@example.com') 138 | refute_nil @person.encrypted_email 139 | refute_equal @person.email, @person.encrypted_email 140 | assert_equal @person.email, Person.first.email 141 | end 142 | 143 | def test_should_marshal_and_encrypt_credentials 144 | @person = Person.create 145 | refute_nil @person.encrypted_credentials 146 | refute_equal @person.credentials, @person.encrypted_credentials 147 | assert_equal @person.credentials, Person.first.credentials 148 | end 149 | 150 | def test_should_encode_by_default 151 | assert Person.attr_encrypted_options[:encode] 152 | end 153 | 154 | def test_should_validate_presence_of_email 155 | @person = PersonWithValidation.new 156 | assert !@person.valid? 157 | assert !@person.errors[:email].empty? || @person.errors.on(:email) 158 | end 159 | 160 | def test_should_encrypt_decrypt_with_iv 161 | @person = Person.create(email: 'test@example.com') 162 | @person2 = Person.find(@person.id) 163 | refute_nil @person2.encrypted_email_iv 164 | assert_equal 'test@example.com', @person2.email 165 | end 166 | 167 | def test_should_ensure_attributes_can_be_deserialized 168 | @person = PersonWithSerialization.new(email: 'test@example.com', password: %w(an array of strings)) 169 | @person.save 170 | assert_equal @person.password, %w(an array of strings) 171 | end 172 | 173 | def test_should_create_an_account_regardless_of_arguments_order 174 | Account.create!(key: SECRET_KEY, password: "password") 175 | Account.create!(password: "password" , key: SECRET_KEY) 176 | end 177 | 178 | def test_should_set_attributes_regardless_of_arguments_order 179 | # minitest does not implement `assert_nothing_raised` https://github.com/seattlerb/minitest/issues/112 180 | Account.new.attributes = { password: "password", key: SECRET_KEY } 181 | end 182 | 183 | def test_should_create_changed_predicate 184 | person = Person.create!(email: 'test@example.com') 185 | refute person.email_changed? 186 | person.email = 'test@example.com' 187 | refute person.email_changed? 188 | person.email = nil 189 | assert person.email_changed? 190 | person.email = 'test2@example.com' 191 | assert person.email_changed? 192 | end 193 | 194 | def test_should_create_was_predicate 195 | original_email = 'test@example.com' 196 | person = Person.create!(email: original_email) 197 | assert_equal original_email, person.email_was 198 | person.email = 'test2@example.com' 199 | assert_equal original_email, person.email_was 200 | old_pm_name = "Winston Churchill" 201 | pm = PrimeMinister.create!(name: old_pm_name) 202 | assert_equal old_pm_name, pm.name_was 203 | old_zipcode = "90210" 204 | address = Address.create!(zipcode: old_zipcode, mode: "single_iv_and_salt") 205 | assert_equal old_zipcode, address.zipcode_was 206 | end 207 | 208 | def test_attribute_was_works_when_options_for_old_encrypted_value_are_different_than_options_for_new_encrypted_value 209 | pw = 'password' 210 | crypto_key = SecureRandom.urlsafe_base64(24) 211 | old_iv = SecureRandom.random_bytes(12) 212 | account = Account.create 213 | encrypted_value = Encryptor.encrypt(value: pw, iv: old_iv, key: crypto_key) 214 | Account.where(id: account.id).update_all(key: crypto_key, encrypted_password_iv: [old_iv].pack('m'), encrypted_password: [encrypted_value].pack('m')) 215 | account = Account.find(account.id) 216 | assert_equal pw, account.password 217 | account.password = pw.reverse 218 | assert_equal pw, account.password_was 219 | account.save 220 | account.reload 221 | assert_equal Account::ACCOUNT_ENCRYPTION_KEY, account.key 222 | assert_equal pw.reverse, account.password 223 | end 224 | 225 | if ::ActiveRecord::VERSION::STRING > "4.0" 226 | def test_should_assign_attributes 227 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 228 | @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login) 229 | assert_equal 'modified', @user.login 230 | end 231 | 232 | def test_should_not_assign_protected_attributes 233 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 234 | @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login) 235 | assert !@user.is_admin? 236 | end 237 | 238 | def test_should_raise_exception_if_not_permitted 239 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 240 | assert_raises ActiveModel::ForbiddenAttributesError do 241 | @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true) 242 | end 243 | end 244 | 245 | def test_should_raise_exception_on_init_if_not_permitted 246 | assert_raises ActiveModel::ForbiddenAttributesError do 247 | @user = UserWithProtectedAttribute.new ActionController::Parameters.new(login: 'modified', is_admin: true) 248 | end 249 | end 250 | else 251 | def test_should_assign_attributes 252 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 253 | @user.attributes = { login: 'modified', is_admin: true } 254 | assert_equal 'modified', @user.login 255 | end 256 | 257 | def test_should_not_assign_protected_attributes 258 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 259 | @user.attributes = { login: 'modified', is_admin: true } 260 | assert !@user.is_admin? 261 | end 262 | 263 | def test_should_assign_protected_attributes 264 | @user = UserWithProtectedAttribute.new(login: 'login', is_admin: false) 265 | if ::ActiveRecord::VERSION::STRING > "3.1" 266 | @user.send(:assign_attributes, { login: 'modified', is_admin: true }, without_protection: true) 267 | else 268 | @user.send(:attributes=, { login: 'modified', is_admin: true }, false) 269 | end 270 | assert @user.is_admin? 271 | end 272 | end 273 | 274 | def test_should_allow_assignment_of_nil_attributes 275 | @person = Person.new 276 | assert_nil(@person.attributes = nil) 277 | end 278 | 279 | def test_should_allow_proc_based_mode 280 | @person = PersonWithProcMode.create(email: 'test@example.com', credentials: 'password123') 281 | 282 | # Email is :per_attribute_iv_and_salt 283 | assert_equal @person.class.encrypted_attributes[:email][:mode].class, Proc 284 | assert_equal @person.class.encrypted_attributes[:email][:mode].call, :per_attribute_iv_and_salt 285 | refute_nil @person.encrypted_email_salt 286 | refute_nil @person.encrypted_email_iv 287 | 288 | # Credentials is :single_iv_and_salt 289 | assert_equal @person.class.encrypted_attributes[:credentials][:mode].class, Proc 290 | assert_equal @person.class.encrypted_attributes[:credentials][:mode].call, :single_iv_and_salt 291 | assert_nil @person.encrypted_credentials_salt 292 | assert_nil @person.encrypted_credentials_iv 293 | end 294 | 295 | if ::ActiveRecord::VERSION::STRING > "3.1" 296 | def test_should_allow_assign_attributes_with_nil 297 | @person = Person.new 298 | assert_nil(@person.assign_attributes nil) 299 | end 300 | end 301 | 302 | def test_that_alias_encrypts_column 303 | user = PersonUsingAlias.new 304 | user.email = 'test@example.com' 305 | user.save 306 | 307 | refute_nil user.encrypted_email 308 | refute_equal user.email, user.encrypted_email 309 | assert_equal user.email, PersonUsingAlias.first.email 310 | end 311 | 312 | # See https://github.com/attr-encrypted/attr_encrypted/issues/68 313 | def test_should_invalidate_virtual_attributes_on_reload 314 | old_pm_name = 'Winston Churchill' 315 | new_pm_name = 'Neville Chamberlain' 316 | pm = PrimeMinister.create!(name: old_pm_name) 317 | assert_equal old_pm_name, pm.name 318 | pm.name = new_pm_name 319 | assert_equal new_pm_name, pm.name 320 | 321 | result = pm.reload 322 | assert_equal pm, result 323 | assert_equal old_pm_name, pm.name 324 | end 325 | 326 | def test_should_save_encrypted_data_as_binary 327 | street = '123 Elm' 328 | address = Address.create!(street: street) 329 | refute_equal address.encrypted_street, street 330 | assert_equal Address.first.street, street 331 | end 332 | 333 | def test_should_evaluate_proc_based_mode 334 | street = '123 Elm' 335 | zipcode = '12345' 336 | address = Address.new(street: street, zipcode: zipcode, mode: :single_iv_and_salt) 337 | assert_nil address.encrypted_zipcode_iv 338 | end 339 | end 340 | -------------------------------------------------------------------------------- /test/attr_encrypted_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require_relative 'test_helper' 3 | 4 | class SillyEncryptor 5 | def self.silly_encrypt(options) 6 | (options[:value] + options[:some_arg]).reverse 7 | end 8 | 9 | def self.silly_decrypt(options) 10 | options[:value].reverse.gsub(/#{options[:some_arg]}$/, '') 11 | end 12 | end 13 | 14 | class User 15 | extend AttrEncrypted 16 | self.attr_encrypted_options[:key] = Proc.new { |user| SECRET_KEY } # default key 17 | self.attr_encrypted_options[:mode] = :per_attribute_iv_and_salt 18 | 19 | attr_encrypted :email, :without_encoding, :key => SECRET_KEY 20 | attr_encrypted :password, :prefix => 'crypted_', :suffix => '_test' 21 | attr_encrypted :ssn, :key => :secret_key, :attribute => 'ssn_encrypted' 22 | attr_encrypted :credit_card, :encryptor => SillyEncryptor, :encrypt_method => :silly_encrypt, :decrypt_method => :silly_decrypt, :some_arg => 'test' 23 | attr_encrypted :with_encoding, :key => SECRET_KEY, :encode => true 24 | attr_encrypted :with_custom_encoding, :key => SECRET_KEY, :encode => 'm' 25 | attr_encrypted :with_marshaling, :key => SECRET_KEY, :marshal => true 26 | attr_encrypted :with_true_if, :key => SECRET_KEY, :if => true, mode: :per_attribute_iv_and_salt 27 | attr_encrypted :with_false_if, :key => SECRET_KEY, :if => false, mode: :per_attribute_iv_and_salt 28 | attr_encrypted :with_true_unless, :key => SECRET_KEY, :unless => true, mode: :per_attribute_iv_and_salt 29 | attr_encrypted :with_false_unless, :key => SECRET_KEY, :unless => false, mode: :per_attribute_iv_and_salt 30 | attr_encrypted :with_if_changed, :key => SECRET_KEY, :if => :should_encrypt 31 | attr_encrypted :with_allow_empty_value, key: SECRET_KEY, allow_empty_value: true, marshal: true 32 | 33 | attr_encryptor :aliased, :key => SECRET_KEY 34 | 35 | attr_accessor :salt 36 | attr_accessor :should_encrypt 37 | 38 | def initialize(email: nil) 39 | self.email = email 40 | self.salt = Time.now.to_i.to_s 41 | self.should_encrypt = true 42 | end 43 | 44 | private 45 | def secret_key 46 | SECRET_KEY 47 | end 48 | end 49 | 50 | class Admin < User 51 | attr_encrypted :testing 52 | end 53 | 54 | class SomeOtherClass 55 | extend AttrEncrypted 56 | def self.call(object) 57 | object.class 58 | end 59 | end 60 | 61 | class YetAnotherClass 62 | extend AttrEncrypted 63 | self.attr_encrypted_options[:encode_iv] = false 64 | 65 | attr_encrypted :email, :key => SECRET_KEY 66 | attr_encrypted :phone_number, :key => SECRET_KEY, mode: Proc.new { |thing| thing.mode }, encode_iv: Proc.new { |thing| thing.encode_iv }, encode_salt: Proc.new { |thing| thing.encode_salt } 67 | 68 | def initialize(email: nil, encode_iv: 'm', encode_salt: 'm', mode: :per_attribute_iv_and_salt) 69 | self.email = email 70 | @encode_iv = encode_iv 71 | @encode_salt = encode_salt 72 | @mode = mode 73 | end 74 | 75 | attr_reader :encode_iv, :encode_salt, :mode 76 | end 77 | 78 | class AttrEncryptedTest < Minitest::Test 79 | def setup 80 | @iv = SecureRandom.random_bytes(12) 81 | end 82 | 83 | def test_should_store_email_in_encrypted_attributes 84 | assert User.encrypted_attributes.include?(:email) 85 | end 86 | 87 | def test_should_not_store_salt_in_encrypted_attributes 88 | refute User.encrypted_attributes.include?(:salt) 89 | end 90 | 91 | def test_attr_encrypted_should_return_true_for_email 92 | assert User.attr_encrypted?('email') 93 | end 94 | 95 | def test_attr_encrypted_should_not_use_the_same_attribute_name_for_two_attributes_in_the_same_line 96 | refute_equal User.encrypted_attributes[:email][:attribute], User.encrypted_attributes[:without_encoding][:attribute] 97 | end 98 | 99 | def test_attr_encrypted_should_return_false_for_salt 100 | assert !User.attr_encrypted?('salt') 101 | end 102 | 103 | def test_should_generate_an_encrypted_attribute 104 | assert User.new.respond_to?(:encrypted_email) 105 | end 106 | 107 | def test_should_generate_an_encrypted_attribute_with_a_prefix_and_suffix 108 | assert User.new.respond_to?(:crypted_password_test) 109 | end 110 | 111 | def test_should_generate_an_encrypted_attribute_with_the_attribute_option 112 | assert User.new.respond_to?(:ssn_encrypted) 113 | end 114 | 115 | def test_should_not_encrypt_nil_value 116 | assert_nil User.encrypt_email(nil, iv: @iv) 117 | end 118 | 119 | def test_should_not_encrypt_empty_string_by_default 120 | assert_equal '', User.encrypt_email('', iv: @iv) 121 | end 122 | 123 | def test_should_encrypt_email 124 | refute_nil User.encrypt_email('test@example.com', iv: @iv) 125 | refute_equal 'test@example.com', User.encrypt_email('test@example.com', iv: @iv) 126 | end 127 | 128 | def test_should_encrypt_email_when_modifying_the_attr_writer 129 | @user = User.new 130 | assert_nil @user.encrypted_email 131 | @user.email = 'test@example.com' 132 | refute_nil @user.encrypted_email 133 | iv = @user.encrypted_email_iv.unpack('m').first 134 | salt = @user.encrypted_email_salt[1..-1].unpack('m').first 135 | assert_equal User.encrypt_email('test@example.com', iv: iv, salt: salt), @user.encrypted_email 136 | end 137 | 138 | def test_should_not_decrypt_nil_value 139 | assert_nil User.decrypt_email(nil, iv: @iv) 140 | end 141 | 142 | def test_should_not_decrypt_empty_string 143 | assert_equal '', User.decrypt_email('', iv: @iv) 144 | end 145 | 146 | def test_should_decrypt_email 147 | encrypted_email = User.encrypt_email('test@example.com', iv: @iv) 148 | refute_equal 'test@test.com', encrypted_email 149 | assert_equal 'test@example.com', User.decrypt_email(encrypted_email, iv: @iv) 150 | end 151 | 152 | def test_should_decrypt_email_when_reading 153 | @user = User.new 154 | assert_nil @user.email 155 | options = @user.encrypted_attributes[:email] 156 | iv = @user.send(:generate_iv, options[:algorithm]) 157 | encoded_iv = [iv].pack(options[:encode_iv]) 158 | salt = SecureRandom.random_bytes 159 | encoded_salt = @user.send(:prefix_and_encode_salt, salt, options[:encode_salt]) 160 | @user.encrypted_email = User.encrypt_email('test@example.com', iv: iv, salt: salt) 161 | @user.encrypted_email_iv = encoded_iv 162 | @user.encrypted_email_salt = encoded_salt 163 | assert_equal 'test@example.com', @user.email 164 | end 165 | 166 | def test_should_encrypt_with_encoding 167 | assert_equal User.encrypt_with_encoding('test', iv: @iv), [User.encrypt_without_encoding('test', iv: @iv)].pack('m') 168 | end 169 | 170 | def test_should_decrypt_with_encoding 171 | encrypted = User.encrypt_with_encoding('test', iv: @iv) 172 | assert_equal 'test', User.decrypt_with_encoding(encrypted, iv: @iv) 173 | assert_equal User.decrypt_with_encoding(encrypted, iv: @iv), User.decrypt_without_encoding(encrypted.unpack('m').first, iv: @iv) 174 | end 175 | 176 | def test_should_encrypt_with_custom_encoding 177 | assert_equal User.encrypt_with_encoding('test', iv: @iv), [User.encrypt_without_encoding('test', iv: @iv)].pack('m') 178 | end 179 | 180 | def test_should_decrypt_with_custom_encoding 181 | encrypted = User.encrypt_with_encoding('test', iv: @iv) 182 | assert_equal 'test', User.decrypt_with_encoding(encrypted, iv: @iv) 183 | assert_equal User.decrypt_with_encoding(encrypted, iv: @iv), User.decrypt_without_encoding(encrypted.unpack('m').first, iv: @iv) 184 | end 185 | 186 | def test_should_encrypt_with_marshaling 187 | @user = User.new 188 | @user.with_marshaling = [1, 2, 3] 189 | refute_nil @user.encrypted_with_marshaling 190 | end 191 | 192 | def test_should_use_custom_encryptor_and_crypt_method_names_and_arguments 193 | assert_equal SillyEncryptor.silly_encrypt(:value => 'testing', :some_arg => 'test'), User.encrypt_credit_card('testing') 194 | end 195 | 196 | def test_should_evaluate_a_key_passed_as_a_symbol 197 | @user = User.new 198 | assert_nil @user.ssn_encrypted 199 | @user.ssn = 'testing' 200 | refute_nil @user.ssn_encrypted 201 | encrypted = Encryptor.encrypt(:value => 'testing', :key => SECRET_KEY, :iv => @user.ssn_encrypted_iv.unpack("m").first, :salt => @user.ssn_encrypted_salt.unpack("m").first ) 202 | assert_equal encrypted, @user.ssn_encrypted 203 | end 204 | 205 | def test_should_evaluate_a_key_passed_as_a_proc 206 | @user = User.new 207 | assert_nil @user.crypted_password_test 208 | @user.password = 'testing' 209 | refute_nil @user.crypted_password_test 210 | encrypted = Encryptor.encrypt(:value => 'testing', :key => SECRET_KEY, :iv => @user.crypted_password_test_iv.unpack("m").first, :salt => @user.crypted_password_test_salt.unpack("m").first) 211 | assert_equal encrypted, @user.crypted_password_test 212 | end 213 | 214 | def test_should_use_options_found_in_the_attr_encrypted_options_attribute 215 | @user = User.new 216 | assert_nil @user.crypted_password_test 217 | @user.password = 'testing' 218 | refute_nil @user.crypted_password_test 219 | encrypted = Encryptor.encrypt(:value => 'testing', :key => SECRET_KEY, :iv => @user.crypted_password_test_iv.unpack("m").first, :salt => @user.crypted_password_test_salt.unpack("m").first) 220 | assert_equal encrypted, @user.crypted_password_test 221 | end 222 | 223 | def test_should_inherit_encrypted_attributes 224 | assert_equal [User.encrypted_attributes.keys, :testing].flatten.collect { |key| key.to_s }.sort, Admin.encrypted_attributes.keys.collect { |key| key.to_s }.sort 225 | end 226 | 227 | def test_should_inherit_attr_encrypted_options 228 | assert !User.attr_encrypted_options.empty? 229 | assert_equal User.attr_encrypted_options, Admin.attr_encrypted_options 230 | end 231 | 232 | def test_should_not_inherit_unrelated_attributes 233 | assert SomeOtherClass.attr_encrypted_options.empty? 234 | assert SomeOtherClass.encrypted_attributes.empty? 235 | end 236 | 237 | def test_should_evaluate_a_symbol_option 238 | assert_equal SomeOtherClass, SomeOtherClass.new.send(:evaluate_attr_encrypted_option, :class) 239 | end 240 | 241 | def test_should_evaluate_a_proc_option 242 | assert_equal SomeOtherClass, SomeOtherClass.new.send(:evaluate_attr_encrypted_option, proc { |object| object.class }) 243 | end 244 | 245 | def test_should_evaluate_a_lambda_option 246 | assert_equal SomeOtherClass, SomeOtherClass.new.send(:evaluate_attr_encrypted_option, lambda { |object| object.class }) 247 | end 248 | 249 | def test_should_evaluate_a_method_option 250 | assert_equal SomeOtherClass, SomeOtherClass.new.send(:evaluate_attr_encrypted_option, SomeOtherClass.method(:call)) 251 | end 252 | 253 | def test_should_return_a_string_option 254 | class_string = 'SomeOtherClass' 255 | assert_equal class_string, SomeOtherClass.new.send(:evaluate_attr_encrypted_option, class_string) 256 | end 257 | 258 | def test_should_encrypt_with_true_if 259 | @user = User.new 260 | assert_nil @user.encrypted_with_true_if 261 | @user.with_true_if = 'testing' 262 | refute_nil @user.encrypted_with_true_if 263 | encrypted = Encryptor.encrypt(:value => 'testing', :key => SECRET_KEY, :iv => @user.encrypted_with_true_if_iv.unpack("m").first, :salt => @user.encrypted_with_true_if_salt.unpack("m").first) 264 | assert_equal encrypted, @user.encrypted_with_true_if 265 | end 266 | 267 | def test_should_not_encrypt_with_false_if 268 | @user = User.new 269 | assert_nil @user.encrypted_with_false_if 270 | @user.with_false_if = 'testing' 271 | refute_nil @user.encrypted_with_false_if 272 | assert_equal 'testing', @user.encrypted_with_false_if 273 | end 274 | 275 | def test_should_encrypt_with_false_unless 276 | @user = User.new 277 | assert_nil @user.encrypted_with_false_unless 278 | @user.with_false_unless = 'testing' 279 | refute_nil @user.encrypted_with_false_unless 280 | encrypted = Encryptor.encrypt(:value => 'testing', :key => SECRET_KEY, :iv => @user.encrypted_with_false_unless_iv.unpack("m").first, :salt => @user.encrypted_with_false_unless_salt.unpack("m").first) 281 | assert_equal encrypted, @user.encrypted_with_false_unless 282 | end 283 | 284 | def test_should_not_encrypt_with_true_unless 285 | @user = User.new 286 | assert_nil @user.encrypted_with_true_unless 287 | @user.with_true_unless = 'testing' 288 | refute_nil @user.encrypted_with_true_unless 289 | assert_equal 'testing', @user.encrypted_with_true_unless 290 | end 291 | 292 | def test_should_encrypt_empty_with_truthy_allow_empty_value_option 293 | @user = User.new 294 | assert_nil @user.encrypted_with_allow_empty_value 295 | @user.with_allow_empty_value = '' 296 | refute_nil @user.encrypted_with_allow_empty_value 297 | assert_equal '', @user.with_allow_empty_value 298 | @user = User.new 299 | @user.with_allow_empty_value = nil 300 | refute_nil @user.encrypted_with_allow_empty_value 301 | assert_nil @user.with_allow_empty_value 302 | end 303 | 304 | def test_should_work_with_aliased_attr_encryptor 305 | assert User.encrypted_attributes.include?(:aliased) 306 | end 307 | 308 | def test_should_always_reset_options 309 | @user = User.new 310 | @user.with_if_changed = "encrypt_stuff" 311 | 312 | @user = User.new 313 | @user.should_encrypt = false 314 | @user.with_if_changed = "not_encrypted_stuff" 315 | assert_equal "not_encrypted_stuff", @user.with_if_changed 316 | assert_equal "not_encrypted_stuff", @user.encrypted_with_if_changed 317 | end 318 | 319 | def test_should_cast_values_as_strings_before_encrypting 320 | string_encrypted_email = User.encrypt_email('3', iv: @iv) 321 | assert_equal string_encrypted_email, User.encrypt_email(3, iv: @iv) 322 | assert_equal '3', User.decrypt_email(string_encrypted_email, iv: @iv) 323 | end 324 | 325 | def test_should_create_query_accessor 326 | @user = User.new 327 | assert !@user.email? 328 | @user.email = '' 329 | assert !@user.email? 330 | @user.email = 'test@example.com' 331 | assert @user.email? 332 | end 333 | 334 | def test_should_vary_iv_per_attribute 335 | @user = User.new 336 | @user.email = 'email@example.com' 337 | @user.password = 'p455w0rd' 338 | refute_equal @user.encrypted_email_iv, @user.crypted_password_test_iv 339 | end 340 | 341 | def test_should_generate_iv_per_attribute_by_default 342 | thing = YetAnotherClass.new(email: 'thing@thing.com') 343 | refute_nil thing.encrypted_email_iv 344 | end 345 | 346 | def test_should_vary_iv_per_instance 347 | @user1 = User.new 348 | @user1.email = 'email@example.com' 349 | @user2 = User.new 350 | @user2.email = 'email@example.com' 351 | refute_equal @user1.encrypted_email_iv, @user2.encrypted_email_iv 352 | refute_equal @user1.encrypted_email, @user2.encrypted_email 353 | end 354 | 355 | def test_should_vary_salt_per_attribute 356 | @user = User.new 357 | @user.email = 'email@example.com' 358 | @user.password = 'p455w0rd' 359 | refute_equal @user.encrypted_email_salt, @user.crypted_password_test_salt 360 | end 361 | 362 | def test_should_vary_salt_per_instance 363 | @user1 = User.new 364 | @user1.email = 'email@example.com' 365 | @user2 = User.new 366 | @user2.email = 'email@example.com' 367 | refute_equal @user1.encrypted_email_salt, @user2.encrypted_email_salt 368 | end 369 | 370 | def test_should_not_generate_salt_per_attribute_by_default 371 | thing = YetAnotherClass.new(email: 'thing@thing.com') 372 | assert_nil thing.encrypted_email_salt 373 | end 374 | 375 | def test_should_decrypt_second_record 376 | @user1 = User.new 377 | @user1.email = 'test@example.com' 378 | 379 | @user2 = User.new 380 | @user2.email = 'test@example.com' 381 | 382 | assert_equal 'test@example.com', @user1.decrypt(:email, @user1.encrypted_email) 383 | end 384 | 385 | def test_should_specify_the_default_algorithm 386 | assert YetAnotherClass.encrypted_attributes[:email][:algorithm] 387 | assert_equal YetAnotherClass.encrypted_attributes[:email][:algorithm], 'aes-256-gcm' 388 | end 389 | 390 | def test_should_not_encode_iv_when_encode_iv_is_false 391 | email = 'thing@thing.com' 392 | thing = YetAnotherClass.new(email: email) 393 | refute thing.encrypted_email_iv =~ base64_encoding_regex 394 | assert_equal thing.email, email 395 | end 396 | 397 | def test_should_base64_encode_iv_by_default 398 | phone_number = '555-555-5555' 399 | thing = YetAnotherClass.new 400 | thing.phone_number = phone_number 401 | assert thing.encrypted_phone_number_iv =~ base64_encoding_regex 402 | assert_equal thing.phone_number, phone_number 403 | end 404 | 405 | def test_should_generate_unique_iv_for_every_encrypt_operation 406 | user = User.new 407 | user.email = 'initial_value@test.com' 408 | original_iv = user.encrypted_email_iv 409 | user.email = 'revised_value@test.com' 410 | refute_equal original_iv, user.encrypted_email_iv 411 | end 412 | 413 | def test_should_not_generate_iv_for_attribute_when_if_option_is_false 414 | user = User.new 415 | user.with_false_if = 'derp' 416 | assert_nil user.encrypted_with_false_if_iv 417 | end 418 | 419 | def test_should_generate_iv_for_attribute_when_if_option_is_true 420 | user = User.new 421 | user.with_true_if = 'derp' 422 | refute_nil user.encrypted_with_true_if_iv 423 | end 424 | 425 | def test_should_not_generate_salt_for_attribute_when_if_option_is_false 426 | user = User.new 427 | user.with_false_if = 'derp' 428 | assert_nil user.encrypted_with_false_if_salt 429 | end 430 | 431 | def test_should_generate_salt_for_attribute_when_if_option_is_true 432 | user = User.new 433 | user.with_true_if = 'derp' 434 | refute_nil user.encrypted_with_true_if_salt 435 | end 436 | 437 | def test_should_generate_iv_for_attribute_when_unless_option_is_false 438 | user = User.new 439 | user.with_false_unless = 'derp' 440 | refute_nil user.encrypted_with_false_unless_iv 441 | end 442 | 443 | def test_should_not_generate_iv_for_attribute_when_unless_option_is_true 444 | user = User.new 445 | user.with_true_unless = 'derp' 446 | assert_nil user.encrypted_with_true_unless_iv 447 | end 448 | 449 | def test_should_generate_salt_for_attribute_when_unless_option_is_false 450 | user = User.new 451 | user.with_false_unless = 'derp' 452 | refute_nil user.encrypted_with_false_unless_salt 453 | end 454 | 455 | def test_should_not_generate_salt_for_attribute_when_unless_option_is_true 456 | user = User.new 457 | user.with_true_unless = 'derp' 458 | assert_nil user.encrypted_with_true_unless_salt 459 | end 460 | 461 | def test_should_not_by_default_generate_iv_when_attribute_is_empty 462 | user = User.new 463 | user.with_true_if = nil 464 | assert_nil user.encrypted_with_true_if_iv 465 | end 466 | end 467 | -------------------------------------------------------------------------------- /lib/attr_encrypted.rb: -------------------------------------------------------------------------------- 1 | require 'encryptor' 2 | 3 | # Adds attr_accessors that encrypt and decrypt an object's attributes 4 | module AttrEncrypted 5 | autoload :Version, 'attr_encrypted/version' 6 | 7 | def self.extended(base) # :nodoc: 8 | base.class_eval do 9 | include InstanceMethods 10 | attr_writer :attr_encrypted_options 11 | @attr_encrypted_options, @encrypted_attributes = {}, {} 12 | end 13 | end 14 | 15 | # Generates attr_accessors that encrypt and decrypt attributes transparently 16 | # 17 | # Options (any other options you specify are passed to the Encryptor's encrypt and decrypt methods) 18 | # 19 | # attribute: The name of the referenced encrypted attribute. For example 20 | # attr_accessor :email, attribute: :ee would generate an 21 | # attribute named 'ee' to store the encrypted email. This is useful when defining 22 | # one attribute to encrypt at a time or when the :prefix and :suffix options 23 | # aren't enough. 24 | # Defaults to nil. 25 | # 26 | # prefix: A prefix used to generate the name of the referenced encrypted attributes. 27 | # For example attr_accessor :email, prefix: 'crypted_' would 28 | # generate attributes named 'crypted_email' to store the encrypted 29 | # email and password. 30 | # Defaults to 'encrypted_'. 31 | # 32 | # suffix: A suffix used to generate the name of the referenced encrypted attributes. 33 | # For example attr_accessor :email, prefix: '', suffix: '_encrypted' 34 | # would generate attributes named 'email_encrypted' to store the 35 | # encrypted email. 36 | # Defaults to ''. 37 | # 38 | # key: The encryption key. This option may not be required if 39 | # you're using a custom encryptor. If you pass a symbol 40 | # representing an instance method then the :key option 41 | # will be replaced with the result of the method before 42 | # being passed to the encryptor. Objects that respond 43 | # to :call are evaluated as well (including procs). 44 | # Any other key types will be passed directly to the encryptor. 45 | # Defaults to nil. 46 | # 47 | # encode: If set to true, attributes will be encoded as well as 48 | # encrypted. This is useful if you're planning on storing 49 | # the encrypted attributes in a database. The default 50 | # encoding is 'm' (base64), however this can be overwritten 51 | # by setting the :encode option to some other encoding 52 | # string instead of just 'true'. See 53 | # http://www.ruby-doc.org/core/classes/Array.html#M002245 54 | # for more encoding directives. 55 | # Defaults to false unless you're using it with ActiveRecord, DataMapper, or Sequel. 56 | # 57 | # encode_iv: Defaults to true. 58 | 59 | # encode_salt: Defaults to true. 60 | # 61 | # default_encoding: Defaults to 'm' (base64). 62 | # 63 | # marshal: If set to true, attributes will be marshaled as well 64 | # as encrypted. This is useful if you're planning on 65 | # encrypting something other than a string. 66 | # Defaults to false. 67 | # 68 | # marshaler: The object to use for marshaling. 69 | # Defaults to Marshal. 70 | # 71 | # dump_method: The dump method name to call on the :marshaler object to. 72 | # Defaults to 'dump'. 73 | # 74 | # load_method: The load method name to call on the :marshaler object. 75 | # Defaults to 'load'. 76 | # 77 | # encryptor: The object to use for encrypting. 78 | # Defaults to Encryptor. 79 | # 80 | # encrypt_method: The encrypt method name to call on the :encryptor object. 81 | # Defaults to 'encrypt'. 82 | # 83 | # decrypt_method: The decrypt method name to call on the :encryptor object. 84 | # Defaults to 'decrypt'. 85 | # 86 | # if: Attributes are only encrypted if this option evaluates 87 | # to true. If you pass a symbol representing an instance 88 | # method then the result of the method will be evaluated. 89 | # Any objects that respond to :call are evaluated as well. 90 | # Defaults to true. 91 | # 92 | # unless: Attributes are only encrypted if this option evaluates 93 | # to false. If you pass a symbol representing an instance 94 | # method then the result of the method will be evaluated. 95 | # Any objects that respond to :call are evaluated as well. 96 | # Defaults to false. 97 | # 98 | # mode: Selects encryption mode for attribute: choose :single_iv_and_salt for compatibility 99 | # with the old attr_encrypted API: the IV is derived from the encryption key by the underlying Encryptor class; salt is not used. 100 | # 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. 101 | # A :per_attribute_iv mode derives a unique IV per attribute; salt is not used. 102 | # Defaults to :per_attribute_iv. 103 | # 104 | # allow_empty_value: Attributes which have nil or empty string values will not be encrypted unless this option 105 | # has a truthy value. 106 | # 107 | # You can specify your own default options 108 | # 109 | # class User 110 | # # Now all attributes will be encoded and marshaled by default 111 | # attr_encrypted_options.merge!(encode: true, marshal: true, some_other_option: true) 112 | # attr_encrypted :configuration, key: 'my secret key' 113 | # end 114 | # 115 | # 116 | # Example 117 | # 118 | # class User 119 | # attr_encrypted :email, key: 'some secret key' 120 | # attr_encrypted :configuration, key: 'some other secret key', marshal: true 121 | # end 122 | # 123 | # @user = User.new 124 | # @user.encrypted_email # nil 125 | # @user.email? # false 126 | # @user.email = 'test@example.com' 127 | # @user.email? # true 128 | # @user.encrypted_email # returns the encrypted version of 'test@example.com' 129 | # 130 | # @user.configuration = { time_zone: 'UTC' } 131 | # @user.encrypted_configuration # returns the encrypted version of configuration 132 | # 133 | # See README for more examples 134 | def attr_encrypted(*attributes) 135 | options = attributes.last.is_a?(Hash) ? attributes.pop : {} 136 | options = attr_encrypted_default_options.dup.merge!(attr_encrypted_options).merge!(options) 137 | 138 | options[:encode] = options[:default_encoding] if options[:encode] == true 139 | options[:encode_iv] = options[:default_encoding] if options[:encode_iv] == true 140 | options[:encode_salt] = options[:default_encoding] if options[:encode_salt] == true 141 | 142 | attributes.each do |attribute| 143 | encrypted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym 144 | 145 | instance_methods_as_symbols = attribute_instance_methods_as_symbols 146 | attr_reader encrypted_attribute_name unless instance_methods_as_symbols.include?(encrypted_attribute_name) 147 | attr_writer encrypted_attribute_name unless instance_methods_as_symbols.include?(:"#{encrypted_attribute_name}=") 148 | 149 | iv_name = "#{encrypted_attribute_name}_iv".to_sym 150 | attr_reader iv_name unless instance_methods_as_symbols.include?(iv_name) 151 | attr_writer iv_name unless instance_methods_as_symbols.include?(:"#{iv_name}=") 152 | 153 | salt_name = "#{encrypted_attribute_name}_salt".to_sym 154 | attr_reader salt_name unless instance_methods_as_symbols.include?(salt_name) 155 | attr_writer salt_name unless instance_methods_as_symbols.include?(:"#{salt_name}=") 156 | 157 | define_method(attribute) do 158 | instance_variable_get("@#{attribute}") || instance_variable_set("@#{attribute}", decrypt(attribute, send(encrypted_attribute_name))) 159 | end 160 | 161 | define_method("#{attribute}=") do |value| 162 | send("#{encrypted_attribute_name}=", encrypt(attribute, value)) 163 | instance_variable_set("@#{attribute}", value) 164 | end 165 | 166 | define_method("#{attribute}?") do 167 | value = send(attribute) 168 | value.respond_to?(:empty?) ? !value.empty? : !!value 169 | end 170 | 171 | encrypted_attributes[attribute.to_sym] = options.merge(attribute: encrypted_attribute_name) 172 | end 173 | end 174 | 175 | alias_method :attr_encryptor, :attr_encrypted 176 | 177 | # Default options to use with calls to attr_encrypted 178 | # 179 | # It will inherit existing options from its superclass 180 | def attr_encrypted_options 181 | @attr_encrypted_options ||= superclass.attr_encrypted_options.dup 182 | end 183 | 184 | def attr_encrypted_default_options 185 | { 186 | prefix: 'encrypted_', 187 | suffix: '', 188 | if: true, 189 | unless: false, 190 | encode: false, 191 | encode_iv: true, 192 | encode_salt: true, 193 | default_encoding: 'm', 194 | marshal: false, 195 | marshaler: Marshal, 196 | dump_method: 'dump', 197 | load_method: 'load', 198 | encryptor: Encryptor, 199 | encrypt_method: 'encrypt', 200 | decrypt_method: 'decrypt', 201 | mode: :per_attribute_iv, 202 | algorithm: 'aes-256-gcm', 203 | allow_empty_value: false, 204 | } 205 | end 206 | 207 | private :attr_encrypted_default_options 208 | 209 | # Checks if an attribute is configured with attr_encrypted 210 | # 211 | # Example 212 | # 213 | # class User 214 | # attr_accessor :name 215 | # attr_encrypted :email 216 | # end 217 | # 218 | # User.attr_encrypted?(:name) # false 219 | # User.attr_encrypted?(:email) # true 220 | def attr_encrypted?(attribute) 221 | encrypted_attributes.has_key?(attribute.to_sym) 222 | end 223 | 224 | # Decrypts a value for the attribute specified 225 | # 226 | # Example 227 | # 228 | # class User 229 | # attr_encrypted :email 230 | # end 231 | # 232 | # email = User.decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING') 233 | def decrypt(attribute, encrypted_value, options = {}) 234 | options = encrypted_attributes[attribute.to_sym].merge(options) 235 | if options[:if] && !options[:unless] && not_empty?(encrypted_value) 236 | encrypted_value = encrypted_value.unpack(options[:encode]).first if options[:encode] 237 | value = options[:encryptor].send(options[:decrypt_method], options.merge!(value: encrypted_value)) 238 | if options[:marshal] 239 | value = options[:marshaler].send(options[:load_method], value) 240 | elsif defined?(Encoding) 241 | encoding = Encoding.default_internal || Encoding.default_external 242 | value = value.force_encoding(encoding.name) 243 | end 244 | value 245 | else 246 | encrypted_value 247 | end 248 | end 249 | 250 | # Encrypts a value for the attribute specified 251 | # 252 | # Example 253 | # 254 | # class User 255 | # attr_encrypted :email 256 | # end 257 | # 258 | # encrypted_email = User.encrypt(:email, 'test@example.com') 259 | def encrypt(attribute, value, options = {}) 260 | options = encrypted_attributes[attribute.to_sym].merge(options) 261 | if options[:if] && !options[:unless] && (options[:allow_empty_value] || not_empty?(value)) 262 | value = options[:marshal] ? options[:marshaler].send(options[:dump_method], value) : value.to_s 263 | encrypted_value = options[:encryptor].send(options[:encrypt_method], options.merge!(value: value)) 264 | encrypted_value = [encrypted_value].pack(options[:encode]) if options[:encode] 265 | encrypted_value 266 | else 267 | value 268 | end 269 | end 270 | 271 | def not_empty?(value) 272 | !value.nil? && !(value.is_a?(String) && value.empty?) 273 | end 274 | 275 | # Contains a hash of encrypted attributes with virtual attribute names as keys 276 | # and their corresponding options as values 277 | # 278 | # Example 279 | # 280 | # class User 281 | # attr_encrypted :email, key: 'my secret key' 282 | # end 283 | # 284 | # User.encrypted_attributes # { email: { attribute: 'encrypted_email', key: 'my secret key' } } 285 | def encrypted_attributes 286 | @encrypted_attributes ||= superclass.encrypted_attributes.dup 287 | end 288 | 289 | # Forwards calls to :encrypt_#{attribute} or :decrypt_#{attribute} to the corresponding encrypt or decrypt method 290 | # if attribute was configured with attr_encrypted 291 | # 292 | # Example 293 | # 294 | # class User 295 | # attr_encrypted :email, key: 'my secret key' 296 | # end 297 | # 298 | # User.encrypt_email('SOME_ENCRYPTED_EMAIL_STRING') 299 | def method_missing(method, *arguments, &block) 300 | if method.to_s =~ /^((en|de)crypt)_(.+)$/ && attr_encrypted?($3) 301 | send($1, $3, *arguments) 302 | else 303 | super 304 | end 305 | end 306 | 307 | module InstanceMethods 308 | # Decrypts a value for the attribute specified using options evaluated in the current object's scope 309 | # 310 | # Example 311 | # 312 | # class User 313 | # attr_accessor :secret_key 314 | # attr_encrypted :email, key: :secret_key 315 | # 316 | # def initialize(secret_key) 317 | # self.secret_key = secret_key 318 | # end 319 | # end 320 | # 321 | # @user = User.new('some-secret-key') 322 | # @user.decrypt(:email, 'SOME_ENCRYPTED_EMAIL_STRING') 323 | def decrypt(attribute, encrypted_value) 324 | encrypted_attributes[attribute.to_sym][:operation] = :decrypting 325 | encrypted_attributes[attribute.to_sym][:value_present] = (encrypted_value && !encrypted_value.empty?) 326 | self.class.decrypt(attribute, encrypted_value, evaluated_attr_encrypted_options_for(attribute)) 327 | end 328 | 329 | # Encrypts a value for the attribute specified using options evaluated in the current object's scope 330 | # 331 | # Example 332 | # 333 | # class User 334 | # attr_accessor :secret_key 335 | # attr_encrypted :email, key: :secret_key 336 | # 337 | # def initialize(secret_key) 338 | # self.secret_key = secret_key 339 | # end 340 | # end 341 | # 342 | # @user = User.new('some-secret-key') 343 | # @user.encrypt(:email, 'test@example.com') 344 | def encrypt(attribute, value) 345 | encrypted_attributes[attribute.to_sym][:operation] = :encrypting 346 | encrypted_attributes[attribute.to_sym][:value_present] = (value && !value.empty?) 347 | self.class.encrypt(attribute, value, evaluated_attr_encrypted_options_for(attribute)) 348 | end 349 | 350 | # Copies the class level hash of encrypted attributes with virtual attribute names as keys 351 | # and their corresponding options as values to the instance 352 | # 353 | def encrypted_attributes 354 | @encrypted_attributes ||= self.class.encrypted_attributes.dup 355 | end 356 | 357 | protected 358 | 359 | # Returns attr_encrypted options evaluated in the current object's scope for the attribute specified 360 | def evaluated_attr_encrypted_options_for(attribute) 361 | evaluated_options = Hash.new 362 | attribute_option_value = encrypted_attributes[attribute.to_sym][:attribute] 363 | encrypted_attributes[attribute.to_sym].map do |option, value| 364 | evaluated_options[option] = evaluate_attr_encrypted_option(value) 365 | end 366 | 367 | evaluated_options[:attribute] = attribute_option_value 368 | 369 | evaluated_options.tap do |options| 370 | if options[:if] && !options[:unless] && options[:value_present] || options[:allow_empty_value] 371 | unless options[:mode] == :single_iv_and_salt 372 | load_iv_for_attribute(attribute, options) 373 | end 374 | 375 | if options[:mode] == :per_attribute_iv_and_salt 376 | load_salt_for_attribute(attribute, options) 377 | end 378 | end 379 | end 380 | end 381 | 382 | # Evaluates symbol (method reference) or proc (responds to call) options 383 | # 384 | # If the option is not a symbol or proc then the original option is returned 385 | def evaluate_attr_encrypted_option(option) 386 | if option.is_a?(Symbol) && respond_to?(option, true) 387 | send(option) 388 | elsif option.respond_to?(:call) 389 | option.call(self) 390 | else 391 | option 392 | end 393 | end 394 | 395 | def load_iv_for_attribute(attribute, options) 396 | encrypted_attribute_name = options[:attribute] 397 | encode_iv = options[:encode_iv] 398 | iv = options[:iv] || send("#{encrypted_attribute_name}_iv") 399 | if options[:operation] == :encrypting 400 | begin 401 | iv = generate_iv(options[:algorithm]) 402 | iv = [iv].pack(encode_iv) if encode_iv 403 | send("#{encrypted_attribute_name}_iv=", iv) 404 | rescue RuntimeError 405 | end 406 | end 407 | if iv && !iv.empty? 408 | iv = iv.unpack(encode_iv).first if encode_iv 409 | options[:iv] = iv 410 | end 411 | end 412 | 413 | def generate_iv(algorithm) 414 | algo = OpenSSL::Cipher.new(algorithm) 415 | algo.encrypt 416 | algo.random_iv 417 | end 418 | 419 | def load_salt_for_attribute(attribute, options) 420 | encrypted_attribute_name = options[:attribute] 421 | encode_salt = options[:encode_salt] 422 | salt = options[:salt] || send("#{encrypted_attribute_name}_salt") 423 | if options[:operation] == :encrypting 424 | salt = SecureRandom.random_bytes 425 | salt = prefix_and_encode_salt(salt, encode_salt) if encode_salt 426 | send("#{encrypted_attribute_name}_salt=", salt) 427 | end 428 | if salt && !salt.empty? 429 | salt = decode_salt_if_encoded(salt, encode_salt) if encode_salt 430 | options[:salt] = salt 431 | end 432 | end 433 | 434 | def prefix_and_encode_salt(salt, encoding) 435 | prefix = '_' 436 | prefix + [salt].pack(encoding) 437 | end 438 | 439 | def decode_salt_if_encoded(salt, encoding) 440 | prefix = '_' 441 | salt.slice(0).eql?(prefix) ? salt.slice(1..-1).unpack(encoding).first : salt 442 | end 443 | end 444 | 445 | protected 446 | 447 | def attribute_instance_methods_as_symbols 448 | instance_methods.collect { |method| method.to_sym } 449 | end 450 | 451 | end 452 | 453 | 454 | Dir[File.join(File.dirname(__FILE__), 'attr_encrypted', 'adapters', '*.rb')].each { |adapter| require adapter } 455 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # attr_encrypted 2 | [![Build Status](https://secure.travis-ci.org/attr-encrypted/attr_encrypted.svg)](https://travis-ci.org/attr-encrypted/attr_encrypted) [![Test Coverage](https://codeclimate.com/github/attr-encrypted/attr_encrypted/badges/coverage.svg)](https://codeclimate.com/github/attr-encrypted/attr_encrypted/coverage) [![Code Climate](https://codeclimate.com/github/attr-encrypted/attr_encrypted/badges/gpa.svg)](https://codeclimate.com/github/attr-encrypted/attr_encrypted) [![Gem Version](https://badge.fury.io/rb/attr_encrypted.svg)](https://badge.fury.io/rb/attr_encrypted) [![security](https://hakiri.io/github/attr-encrypted/attr_encrypted/master.svg)](https://hakiri.io/github/attr-encrypted/attr_encrypted/master) 3 | 4 | Generates attr_accessors that transparently encrypt and decrypt attributes. 5 | 6 | It works with ANY class, however, you get a few extra features when you're using it with `ActiveRecord`, `DataMapper`, or `Sequel`. 7 | 8 | 9 | ## Installation 10 | 11 | Add attr_encrypted to your gemfile: 12 | 13 | ```ruby 14 | gem "attr_encrypted", "~> 3.0.0" 15 | ``` 16 | 17 | Then install the gem: 18 | 19 | ```bash 20 | bundle install 21 | ``` 22 | 23 | ## Usage 24 | 25 | If you're using an ORM like `ActiveRecord`, `DataMapper`, or `Sequel`, using attr_encrypted is easy: 26 | 27 | ```ruby 28 | class User 29 | attr_encrypted :ssn, key: 'This is a key that is 256 bits!!' 30 | end 31 | ``` 32 | 33 | If you're using a PORO, you have to do a little bit more work by extending the class: 34 | 35 | ```ruby 36 | class User 37 | extend AttrEncrypted 38 | attr_accessor :name 39 | attr_encrypted :ssn, key: 'This is a key that is 256 bits!!' 40 | 41 | def load 42 | # loads the stored data 43 | end 44 | 45 | def save 46 | # saves the :name and :encrypted_ssn attributes somewhere (e.g. filesystem, database, etc) 47 | end 48 | end 49 | 50 | user = User.new 51 | user.ssn = '123-45-6789' 52 | user.ssn # returns the unencrypted object ie. '123-45-6789' 53 | user.encrypted_ssn # returns the encrypted version of :ssn 54 | user.save 55 | 56 | user = User.load 57 | user.ssn # decrypts :encrypted_ssn and returns '123-45-6789' 58 | ``` 59 | 60 | ### Encrypt/decrypt attribute class methods 61 | 62 | Two class methods are available for each attribute: `User.encrypt_email` and `User.decrypt_email`. They accept as arguments the same options that the `attr_encrypted` class method accepts. For example: 63 | 64 | ```ruby 65 | key = SecureRandom.random_bytes(32) 66 | iv = SecureRandom.random_bytes(12) 67 | encrypted_email = User.encrypt_email('test@test.com', iv: iv, key: key) 68 | email = User.decrypt_email(encrypted_email, iv: iv, key: key) 69 | ``` 70 | 71 | The `attr_encrypted` class method is also aliased as `attr_encryptor` to conform to Ruby's `attr_` naming conventions. I should have called this project `attr_encryptor` but it was too late when I realized it ='(. 72 | 73 | ### attr_encrypted with database persistence 74 | 75 | By default, `attr_encrypted` uses the `:per_attribute_iv` encryption mode. This mode requires a column to store your cipher text and a column to store your IV. 76 | 77 | Create or modify the table that your model uses to add a column with the `encrypted_` prefix (which can be modified, see below), e.g. `encrypted_ssn` via a migration like the following: 78 | 79 | ```ruby 80 | create_table :users do |t| 81 | t.string :name 82 | t.string :encrypted_ssn 83 | t.string :encrypted_ssn_iv 84 | t.timestamps 85 | end 86 | ``` 87 | 88 | You can use a string or binary column type. (See the encode option section below for more info) 89 | 90 | ### Specifying the encrypted attribute name 91 | 92 | By default, the encrypted attribute name is `encrypted_#{attribute}` (e.g. `attr_encrypted :email` would create an attribute named `encrypted_email`). So, if you're storing the encrypted attribute in the database, you need to make sure the `encrypted_#{attribute}` field exists in your table. You have a couple of options if you want to name your attribute or db column something else, see below for more details. 93 | 94 | 95 | ## attr_encrypted options 96 | 97 | #### Options are evaluated 98 | All options will be evaluated at the instance level. If you pass in a symbol it will be passed as a message to the instance of your class. If you pass a proc or any object that responds to `:call` it will be called. You can pass in the instance of your class as an argument to the proc. Anything else will be returned. For example: 99 | 100 | ##### Symbols representing instance methods 101 | 102 | Here is an example class that uses an instance method to determines the encryption key to use. 103 | 104 | ```ruby 105 | class User 106 | attr_encrypted :email, key: :encryption_key 107 | 108 | def encryption_key 109 | # does some fancy logic and returns an encryption key 110 | end 111 | end 112 | ``` 113 | 114 | 115 | ##### Procs 116 | 117 | Here is an example of passing a proc/lambda object as the `:key` option as well: 118 | 119 | ```ruby 120 | class User 121 | attr_encrypted :email, key: proc { |user| user.key } 122 | end 123 | ``` 124 | 125 | 126 | ### Default options 127 | 128 | The following are the default options used by `attr_encrypted`: 129 | 130 | ```ruby 131 | prefix: 'encrypted_', 132 | suffix: '', 133 | if: true, 134 | unless: false, 135 | encode: false, 136 | encode_iv: true, 137 | encode_salt: true, 138 | default_encoding: 'm', 139 | marshal: false, 140 | marshaler: Marshal, 141 | dump_method: 'dump', 142 | load_method: 'load', 143 | encryptor: Encryptor, 144 | encrypt_method: 'encrypt', 145 | decrypt_method: 'decrypt', 146 | mode: :per_attribute_iv, 147 | algorithm: 'aes-256-gcm', 148 | allow_empty_value: false 149 | ``` 150 | 151 | All of the aforementioned options are explained in depth below. 152 | 153 | Additionally, you can specify default options for all encrypted attributes in your class. Instead of having to define your class like this: 154 | 155 | ```ruby 156 | class User 157 | attr_encrypted :email, key: 'This is a key that is 256 bits!!', prefix: '', suffix: '_crypted' 158 | attr_encrypted :ssn, key: 'a different secret key', prefix: '', suffix: '_crypted' 159 | attr_encrypted :credit_card, key: 'another secret key', prefix: '', suffix: '_crypted' 160 | end 161 | ``` 162 | 163 | You can simply define some default options like so: 164 | 165 | ```ruby 166 | class User 167 | attr_encrypted_options.merge!(prefix: '', :suffix => '_crypted') 168 | attr_encrypted :email, key: 'This is a key that is 256 bits!!' 169 | attr_encrypted :ssn, key: 'a different secret key' 170 | attr_encrypted :credit_card, key: 'another secret key' 171 | end 172 | ``` 173 | 174 | This should help keep your classes clean and DRY. 175 | 176 | ### The `:attribute` option 177 | 178 | You can simply pass the name of the encrypted attribute as the `:attribute` option: 179 | 180 | ```ruby 181 | class User 182 | attr_encrypted :email, key: 'This is a key that is 256 bits!!', attribute: 'email_encrypted' 183 | end 184 | ``` 185 | 186 | This would generate an attribute named `email_encrypted` 187 | 188 | 189 | ### The `:prefix` and `:suffix` options 190 | 191 | If you don't like the `encrypted_#{attribute}` naming convention then you can specify your own: 192 | 193 | ```ruby 194 | class User 195 | attr_encrypted :email, key: 'This is a key that is 256 bits!!', prefix: 'secret_', suffix: '_crypted' 196 | end 197 | ``` 198 | 199 | This would generate the following attribute: `secret_email_crypted`. 200 | 201 | 202 | ### The `:key` option 203 | 204 | The `:key` option is used to pass in a data encryption key to be used with whatever encryption class you use. If you're using `Encryptor`, the key must meet minimum length requirements respective to the algorithm that you use; aes-256 requires a 256 bit key, etc. The `:key` option is not required (see custom encryptor below). 205 | 206 | 207 | ##### Unique keys for each attribute 208 | 209 | You can specify unique keys for each attribute if you'd like: 210 | 211 | ```ruby 212 | class User 213 | attr_encrypted :email, key: 'This is a key that is 256 bits!!' 214 | attr_encrypted :ssn, key: 'a different secret key' 215 | end 216 | ``` 217 | 218 | It is recommended to use a symbol or a proc for the key and to store information regarding what key was used to encrypt your data. (See below for more details.) 219 | 220 | 221 | ### The `:if` and `:unless` options 222 | 223 | There may be times that you want to only encrypt when certain conditions are met. For example maybe you're using rails and you don't want to encrypt attributes when you're in development mode. You can specify conditions like this: 224 | 225 | ```ruby 226 | class User < ActiveRecord::Base 227 | attr_encrypted :email, key: 'This is a key that is 256 bits!!', unless: Rails.env.development? 228 | attr_encrypted :ssn, key: 'This is a key that is 256 bits!!', if: Rails.env.development? 229 | end 230 | ``` 231 | 232 | You can specify both `:if` and `:unless` options. 233 | 234 | 235 | ### The `:encryptor`, `:encrypt_method`, and `:decrypt_method` options 236 | 237 | The `Encryptor` class is used by default. You may use your own custom encryptor by specifying the `:encryptor`, `:encrypt_method`, and `:decrypt_method` options. 238 | 239 | Lets suppose you'd like to use this custom encryptor class: 240 | 241 | ```ruby 242 | class SillyEncryptor 243 | def self.silly_encrypt(options) 244 | (options[:value] + options[:secret_key]).reverse 245 | end 246 | 247 | def self.silly_decrypt(options) 248 | options[:value].reverse.gsub(/#{options[:secret_key]}$/, '') 249 | end 250 | end 251 | ``` 252 | 253 | Simply set up your class like so: 254 | 255 | ```ruby 256 | class User 257 | attr_encrypted :email, secret_key: 'This is a key that is 256 bits!!', encryptor: SillyEncryptor, encrypt_method: :silly_encrypt, decrypt_method: :silly_decrypt 258 | end 259 | ``` 260 | 261 | Any options that you pass to `attr_encrypted` will be passed to the encryptor class along with the `:value` option which contains the string to encrypt/decrypt. Notice that the above example uses `:secret_key` instead of `:key`. See [encryptor](https://github.com/attr-encrypted/encryptor) for more info regarding the default encryptor class. 262 | 263 | 264 | ### The `:mode` option 265 | 266 | The mode options allows you to specify in what mode your data will be encrypted. There are currently three modes: `:per_attribute_iv`, `:per_attribute_iv_and_salt`, and `:single_iv_and_salt`. 267 | 268 | __NOTE: `:per_attribute_iv_and_salt` and `:single_iv_and_salt` modes are deprecated and will be removed in the next major release.__ 269 | 270 | 271 | ### The `:algorithm` option 272 | 273 | The default `Encryptor` class uses the standard ruby OpenSSL library. Its default algorithm is `aes-256-gcm`. You can modify this by passing the `:algorithm` option to the `attr_encrypted` call like so: 274 | 275 | ```ruby 276 | class User 277 | attr_encrypted :email, key: 'This is a key that is 256 bits!!', algorithm: 'aes-256-cbc' 278 | end 279 | ``` 280 | 281 | To view a list of all cipher algorithms that are supported on your platform, run the following code in your favorite Ruby REPL: 282 | 283 | ```ruby 284 | require 'openssl' 285 | puts OpenSSL::Cipher.ciphers 286 | ``` 287 | See [Encryptor](https://github.com/attr-encrypted/encryptor#algorithms) for more information. 288 | 289 | 290 | ### The `:encode`, `:encode_iv`, `:encode_salt`, and `:default_encoding` options 291 | 292 | You're probably going to be storing your encrypted attributes somehow (e.g. filesystem, database, etc). You can simply pass the `:encode` option to automatically encode/decode when encrypting/decrypting. The default behavior assumes that you're using a string column type and will base64 encode your cipher text. If you choose to use the binary column type then encoding is not required, but be sure to pass in `false` with the `:encode` option. 293 | 294 | ```ruby 295 | class User 296 | attr_encrypted :email, key: 'some secret key', encode: true, encode_iv: true, encode_salt: true 297 | end 298 | ``` 299 | 300 | The default encoding is `m` (base64). You can change this by setting `encode: 'some encoding'`. See [`Array#pack`](http://ruby-doc.org/core-2.3.0/Array.html#method-i-pack) for more encoding options. 301 | 302 | 303 | ### The `:marshal`, `:dump_method`, and `:load_method` options 304 | 305 | You may want to encrypt objects other than strings (e.g. hashes, arrays, etc). If this is the case, simply pass the `:marshal` option to automatically marshal when encrypting/decrypting. 306 | 307 | ```ruby 308 | class User 309 | attr_encrypted :credentials, key: 'some secret key', marshal: true 310 | end 311 | ``` 312 | 313 | You may also optionally specify `:marshaler`, `:dump_method`, and `:load_method` if you want to use something other than the default `Marshal` object. 314 | 315 | ### The `:allow_empty_value` option 316 | 317 | You may want to encrypt empty strings or nil so as to not reveal which records are populated and which records are not. 318 | 319 | ```ruby 320 | class User 321 | attr_encrypted :credentials, key: 'some secret key', marshal: true, allow_empty_value: true 322 | end 323 | ``` 324 | 325 | 326 | ## ORMs 327 | 328 | ### ActiveRecord 329 | 330 | If you're using this gem with `ActiveRecord`, you get a few extra features: 331 | 332 | #### Default options 333 | 334 | The `:encode` option is set to true by default. 335 | 336 | #### Dynamic `find_by_` and `scoped_by_` methods 337 | 338 | Let's say you'd like to encrypt your user's email addresses, but you also need a way for them to login. Simply set up your class like so: 339 | 340 | ```ruby 341 | class User < ActiveRecord::Base 342 | attr_encrypted :email, key: 'This is a key that is 256 bits!!' 343 | attr_encrypted :password, key: 'some other secret key' 344 | end 345 | ``` 346 | 347 | You can now lookup and login users like so: 348 | 349 | ```ruby 350 | User.find_by_email_and_password('test@example.com', 'testing') 351 | ``` 352 | 353 | The call to `find_by_email_and_password` is intercepted and modified to `find_by_encrypted_email_and_encrypted_password('ENCRYPTED EMAIL', 'ENCRYPTED PASSWORD')`. The dynamic scope methods like `scoped_by_email_and_password` work the same way. 354 | 355 | NOTE: This only works if all records are encrypted with the same encryption key (per attribute). 356 | 357 | __NOTE: This feature is deprecated and will be removed in the next major release.__ 358 | 359 | 360 | ### DataMapper and Sequel 361 | 362 | #### Default options 363 | 364 | The `:encode` option is set to true by default. 365 | 366 | 367 | ## Deprecations 368 | 369 | attr_encrypted v2.0.0 now depends on encryptor v2.0.0. As part of both major releases many insecure defaults and behaviors have been deprecated. The new default behavior is as follows: 370 | 371 | * Default `:mode` is now `:per_attribute_iv`, the default `:mode` in attr_encrypted v1.x was `:single_iv_and_salt`. 372 | * Default `:algorithm` is now 'aes-256-gcm', the default `:algorithm` in attr_encrypted v1.x was 'aes-256-cbc'. 373 | * The encryption key provided must be of appropriate length respective to the algorithm used. Previously, encryptor did not verify minimum key length. 374 | * The dynamic finders available in ActiveRecord will only work with `:single_iv_and_salt` mode. It is strongly advised that you do not use this mode. If you can search the encrypted data, it wasn't encrypted securely. This functionality will be deprecated in the next major release. 375 | * `:per_attribute_iv_and_salt` and `:single_iv_and_salt` modes are deprecated and will be removed in the next major release. 376 | 377 | Backwards compatibility is supported by providing a special option that is passed to encryptor, namely, `:insecure_mode`: 378 | 379 | ```ruby 380 | class User 381 | attr_encrypted :email, key: 'a secret key', algorithm: 'aes-256-cbc', mode: :single_iv_and_salt, insecure_mode: true 382 | end 383 | ``` 384 | 385 | The `:insecure_mode` option will allow encryptor to ignore the new security requirements. It is strongly advised that if you use this older insecure behavior that you migrate to the newer more secure behavior. 386 | 387 | 388 | ## Upgrading from attr_encrypted v1.x to v3.x 389 | 390 | Modify your gemfile to include the new version of attr_encrypted: 391 | 392 | ```ruby 393 | gem attr_encrypted, "~> 3.0.0" 394 | ``` 395 | 396 | The update attr_encrypted: 397 | 398 | ```bash 399 | bundle update attr_encrypted 400 | ``` 401 | 402 | Then modify your models using attr\_encrypted to account for the changes in default options. Specifically, pass in the `:mode` and `:algorithm` options that you were using if you had not previously done so. If your key is insufficient length relative to the algorithm that you use, you should also pass in `insecure_mode: true`; this will prevent Encryptor from raising an exception regarding insufficient key length. Please see the Deprecations sections for more details including an example of how to specify your model with default options from attr_encrypted v1.x. 403 | 404 | ## Upgrading from attr_encrypted v2.x to v3.x 405 | 406 | A bug was discovered in Encryptor v2.0.0 that inccorectly set the IV when using an AES-\*-GCM algorithm. Unfornately fixing this major security issue results in the inability to decrypt records encrypted using an AES-*-GCM algorithm from Encryptor v2.0.0. Please see [Upgrading to Encryptor v3.0.0](https://github.com/attr-encrypted/encryptor#upgrading-from-v200-to-v300) for more info. 407 | 408 | It is strongly advised that you re-encrypt your data encrypted with Encryptor v2.0.0. However, you'll have to take special care to re-encrypt. To decrypt data encrypted with Encryptor v2.0.0 using an AES-\*-GCM algorithm you can use the `:v2_gcm_iv` option. 409 | 410 | It is recommended that you implement a strategy to insure that you do not mix the encryption implementations of Encryptor. One way to do this is to re-encrypt everything while your application is offline.Another way is to add a column that keeps track of what implementation was used. The path that you choose will depend on your situtation. Below is an example of how you might go about re-encrypting your data. 411 | 412 | ```ruby 413 | class User 414 | attr_encrypted :ssn, key: :encryption_key, v2_gcm_iv: is_decrypting?(:ssn) 415 | 416 | def is_decrypting?(attribute) 417 | encrypted_attributes[attribute][:operation] == :decrypting 418 | end 419 | end 420 | 421 | User.all.each do |user| 422 | old_ssn = user.ssn 423 | user.ssn= old_ssn 424 | user.save 425 | end 426 | ``` 427 | 428 | ## Things to consider before using attr_encrypted 429 | 430 | #### Searching, joining, etc 431 | While choosing to encrypt at the attribute level is the most secure solution, it is not without drawbacks. Namely, you cannot search the encrypted data, and because you can't search it, you can't index it either. You also can't use joins on the encrypted data. Data that is securely encrypted is effectively noise. So any operations that rely on the data not being noise will not work. If you need to do any of the aforementioned operations, please consider using database and file system encryption along with transport encryption as it moves through your stack. 432 | 433 | #### Data leaks 434 | Please also consider where your data leaks. If you're using attr_encrypted with Rails, it's highly likely that this data will enter your app as a request parameter. You'll want to be sure that you're filtering your request params from you logs or else your data is sitting in the clear in your logs. [Parameter Filtering in Rails](http://apidock.com/rails/ActionDispatch/Http/FilterParameters) Please also consider other possible leak points. 435 | 436 | #### Storage requirements 437 | When storing your encrypted data, please consider the length requirements of the db column that you're storing the cipher text in. Older versions of Mysql attempt to 'help' you by truncating strings that are too large for the column. When this happens, you will not be able to decrypt your data. [MySQL Strict Trans](http://www.davidpashley.com/2009/02/15/silently-truncated/) 438 | 439 | #### Metadata regarding your crypto implementation 440 | It is advisable to also store metadata regarding the circumstances of your encrypted data. Namely, you should store information about the key used to encrypt your data, as well as the algorithm. Having this metadata with every record will make key rotation and migrating to a new algorithm signficantly easier. It will allow you to continue to decrypt old data using the information provided in the metadata and new data can be encrypted using your new key and algorithm of choice. 441 | 442 | #### Enforcing the IV as a nonce 443 | On a related note, most alorithms require that your IV be unique for every record and key combination. You can enforce this using composite unique indexes on your IV and encryption key name/id column. [RFC 5084](https://tools.ietf.org/html/rfc5084#section-1.5) 444 | 445 | #### Unique key per record 446 | Lastly, while the `:per_attribute_iv_and_salt` mode is more secure than `:per_attribute_iv` mode because it uses a unique key per record, it uses a PBKDF function which introduces a huge performance hit (175x slower by my benchmarks). There are other ways of deriving a unique key per record that would be much faster. 447 | 448 | ## Note on Patches/Pull Requests 449 | 450 | * Fork the project. 451 | * Make your feature addition or bug fix. 452 | * Add tests for it. This is important so I don't break it in a 453 | future version unintentionally. 454 | * Commit, do not mess with rakefile, version, changelog, or history. 455 | * Send me a pull request. Bonus points for topic branches. 456 | --------------------------------------------------------------------------------