Native encrypted attributes for Rails ActiveModel
Sometimes you come across a situation where you need to store sensitive information in a database. Perhaps compliance with HIPAA or for other reasons. You may be surprised to learn that Rails can handle almost any rich attribute type as an object. In fact, Rails does this transparently with Date/Time objects, Booleans, and other data types. When you query the database, Rails serializes model attributes from objects, and casts them back to rich objects when fetching data. Let's take a look at how we can use this to create our own value type to serialize attributes to encrypted text, and cast encrypted text back to the original value.
First we need to write a class that will encrypt text. This is fairly straightforward using the built in OpenSSL
class with an AES algorithm.
# app/lib/encryptor.rb
class Encryptor
def initialize(key: Rails.application.secret_key_base)
@key = key[0...32]
end
def encrypt(unencrypted_string)
_cipher = OpenSSL::Cipher.new('AES-256-CBC').encrypt
_cipher.key = @key
encrypted_string = _cipher.update(unencrypted_string) + _cipher.final
Base64.encode64(encrypted_string.unpack('H*')[0].upcase).chomp
end
def decrypt(encrypted_string)
_cipher = OpenSSL::Cipher.new('AES-256-CBC').decrypt
_cipher.key = @key
encrypted_string = [Base64.decode64(encrypted_string)].pack("H*")
unencrypted_string = _cipher.update(encrypted_string) + _cipher.final
end
end
Let's test that this works. I'm using RSpec here.
# spec/lib/encryptor_spec.rb
require 'rails_helper'
RSpec.describe Encryptor do
# randomly generated hex string (32)
let(:key) { '1a1510e5e7775a5e9d2efcc3d9851f66' }
it 'Encrypts text' do
encryptor = Encryptor.new(key: key)
out = encryptor.encrypt('secret')
expect(out).to eq('QUJEQjc3OUJCNkRCODc0OUMyOTkwOEY0NUFBMkJDOTg=')
end
it 'Decrypts text' do
encryptor = Encryptor.new(key: key)
out = encryptor.decrypt('QUJEQjc3OUJCNkRCODc0OUMyOTkwOEY0NUFBMkJDOTg=')
expect(out).to eq('secret')
end
end
Great, now that we can encrypt text, we can write our value type class that will be responsible for casting and serializing the encrypted text. For this, we can turn to the ActiveModel::Type::Value
class. This class is responsible primarily for the two methods mentioned above: cast
and serialize
.
Looking at the active-model source code we can see all the other types that also inherit from ActiveModel::Type::Value
.
Here's my implementation of this as EncType
:
# app/lib/enc_type.rb
class EncType < ActiveModel::Type::Value
def cast(value)
encryptor.decrypt(value) if value
rescue OpenSSL::Cipher::CipherError
value
end
def serialize(value)
encryptor.encrypt(value) if value
end
end
Now, let's create a model to test this with. I'm going to create a simple user model with 2 attributes: email
and password_digest
.
$ rails generate model user email:index password:digest
$ rails db:migrate
Here's the migration Rails generated for us:
# db/migrate/create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :email
t.string :password_digest
t.timestamps
end
add_index :users, :email
end
end
Now, open the user model, and add our new EncType
as the email attribute's type:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
attribute :email, EncType.new
end
Now, all that's left is to test if this worked:
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
it "encrypts and decrypts the email attr" do
user = User.create email: 'test@example.com', password: 'secret'
sql = <<~SQL
select email from #{User.table_name} where id = #{user.id}
SQL
result = ActiveRecord::Base.connection.execute(sql)
# The result from raw sql should be encrypted
expect(result.first['email']).not_to eq('test@example.com')
# You can even search by the unencrypted value:
expect(User.find_by(email: 'test@example.com').id).to eq(user.id)
end
end
If everything went according to plan, we should now have 3 green dots:
$ rspec
...