See more posts

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.

ruby
# 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.

ruby
# 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 :

ruby
# 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 .

zsh
$ rails generate model user email:index password:digest
$ rails db:migrate

Here's the migration Rails generated for us:

ruby
# 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:

ruby
# 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:

ruby
# 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:

text
$ rspec
...

Thanks for reading! See more posts Leave feedback