jmazzi / crypt_keeper

Transparent ActiveRecord encryption
http://jmazzi.github.com/crypt_keeper/
MIT License
288 stars 99 forks source link

validates_uniqueness_of :encrypted_attribute Fails #132

Closed zeknox closed 7 years ago

zeknox commented 7 years ago

Perhaps this is expected behavior and I'm missing something. Having issues with certain validations failing once the crypt_keeper method is applied to the attribute. For example I have a model which defines encryption for the title attribute:

crypt_keeper :title, encryptor: :aes_new, key: 'super_good_password', salt: 'salt'

Once I add the title attribute to crypt_keeper the uniqueness validation will fail. Below is the validation:

validates_uniqueness_of :title, scope: :assessment_component

Rspec test syntax:

it { should validate_uniqueness_of(:title).scoped_to(:assessment_component_id) }

Test output when actually run with rspec:

  1) AssessmentFinding should require case sensitive unique value for title scoped to assessment_component_id
     Failure/Error: it { should validate_uniqueness_of(:title).scoped_to(:assessment_component_id) }

       Expected errors to include "has already been taken" when title is set to "a",
       got no errors
     # ./spec/models/assessment_finding_spec.rb:33:in `block (2 levels) in <top (required)>'

Any insight of how I can properly test the uniqueness of this attribute and still provide encryption?

itspriddle commented 7 years ago

This is expected behavior with the AES encryptor. The encryptor's usage of AES.encrypt generates a different initialization vector (iv) for each encrypt call, so you see different ciphertext for the same plaintext/key.

>> AES.encrypt("foo", "secret")
"hhOfJ+F2mUgA2h0XpNIUTA==$BHdRvIJQZSubl6gHJ+nhqw=="
>> AES.encrypt("foo", "secret")
"AlpzfBRR2toFoQOlS8G5sQ==$JnNoXcOcWshvcT6xxkst7Q=="

ActiveRecord uses the ciphertext when validating uniqueness, not the plaintext. So the test is actually failing correctly if the expectation is that the plaintext is unique.

If you are trying to validate the ciphertext itself is unique, you can try stubbing AES.encrypt in your spec so it returns the same value and your test would run as expected.

If you are trying to validate the plaintext is unique, there are a few options you can try.

Use :mysql_aes_new

MySQL's AES_ENCRYPT function returns the same ciphertext when passed the same plaintext/key. If you are using MySQL for your DB and don't specifically need to use the AES gem, this is probably the quickest thing to do.

Use a static iv with AES

AES.encrypt accepts an iv parameter. If you use the same iv the ciphertext will be the same.

>> iv = AES.iv(:base_64)
"qswUlsJEkWCcRXClFgz51Q=="
>> AES.encrypt("foo", "key", iv: iv)
"qswUlsJEkWCcRXClFgz51Q==$5ywZjOowOmnt5CXXWwzsxg=="
>> AES.encrypt("foo", "key", iv: iv)
"qswUlsJEkWCcRXClFgz51Q==$5ywZjOowOmnt5CXXWwzsxg=="

To enable this in CryptKeeper you can create your own encryptor or just monkey patch:

# Probably want to store this securely
MY_IV = "qswUlsJEkWCcRXClFgz51Q=="

CryptKeeper::Provider::AesNew.prepend Module.new {
  def encrypt(value)
    AES.encrypt(value, key, iv: MY_IV)
  end
}

Use a separate hash column

You can store a hash of the plaintext in a separate column and check that for uniqueness:

class User
  validates_uniqueness_of :name_hash

  before_validation do
    self.name_hash = Digest::SHA1.hexdigest(name)
  end
end

Manually validate

If your table is small and you can afford a full-table scan you could do something like:

class User
  validate :unique_name

  def unique_name
    used = self.class.all.any? do |user|
      user.name == name
    end
    errors.add :name, "is already taken" if used
  end
end