Read more

Rails: Encrypting your database information using Active Record Encryption

Emanuel
February 23, 2023Software engineer at makandra GmbH

Since Rails 7 you are able to encrypt database information with Active Record Show archive.org snapshot . Using Active Record Encryption will store an attribute as string in the database. And uses JSON for serializing the encrypted attribute.

Illustration UI/UX Design

UI/UX Design by makandra brand

We make sure that your target audience has the best possible experience with your digital product. You get:

  • Design tailored to your audience
  • Proven processes customized to your needs
  • An expert team of experienced designers
Read more Show archive.org snapshot

Example:

  • p: Payload
  • h: Headers
  • iv: Initialization Vector
  • at: Authentication Tag
{ "p": "n7J0/ol+a7DRMeaE", "h": { "iv": "DXZMDWUKfp3bg/Yu", "at": "X1/YjMHbHD4talgF9dt61A=="} }

Note this before encrypting attributes with Active Record:

  • You need to choose between a non-deterministic and deterministic mode. The non-deterministic mode is the default and will result in a different payload for the same value. In case you need a unique index e.g. for a email attribute, you need to use the deterministic mode.
  • Encrypting attributes will prevent you from using usual database queries. Searching for substrings, grouping by an attribute, bulk updates and many more. Regular where-Queries will work for deterministic columns.

Example integration with devise

Here is an example encrypting a user table managed by devise Show archive.org snapshot . It could help you to get a rough idea of the necessary steps you need to consider when introduction Active Record Encryption in your application.

class User < ApplicationRecord
  devise :database_authenticatable,
    :registerable,
    :recoverable,
    :rememberable,
    :confirmable

  validates :name, presence: true

  encrypts :email, deterministic: true, downcase: true
  encrypts :unconfirmed_email, deterministic: true, downcase: true
  encrypts :name
end

Using a secrets.yml instead of the credential store

You need to configure your Active Record Encryption keys manually in the config/application.rb:

config.active_record.encryption.primary_key = Rails.application.secrets.dig(:active_record_encryption, :primary_key)
config.active_record.encryption.deterministic_key = Rails.application.secrets.dig(:active_record_encryption, :deterministic_key)
config.active_record.encryption.key_derivation_salt = Rails.application.secrets.dig(:active_record_encryption, :key_derivation_salt)

Your config/secrets.yml can be filled with the values from bin/rails db:encryption:init for each environment.

active_record_encryption:
  primary_key: <some-secret>
  deterministic_key: <some-secret>
  key_derivation_salt: <some-secret>

Migrating existing users

If you already have values in your user database, a good default is to enforce all existing data to be encrypted. Another option would be to encrypt attributes on the fly, when a record is updated.

class EncryptUserAttributes < ActiveRecord::Migration[7.0]

  # It would have been possible to use the EncryptableRecord API with a `previous` option:
  #
  # ```
  # encrypts :email, deterministic: true, downcase: true, previous: { encryptor: ActiveRecord::Encryption::NullEncryptor.new }
  # ```
  #
  # But in the down migration it was difficult to use the Encryption::NullEncryptor as main encryptor and Encryption::Encryptor
  # as previous encryptor. The NullEncryptor will never raise an Encryption::Errors::Base since an encrypted string would still
  # be valid for the NullEncryptor and therefore the previous encrypted is never called.
  #
  # In this approach the implementation of a smart NullEncryptor was skipped and the relevant parts from EncryptableRecord
  # where extracted to reduce the amount of Rails magic.

  class User < ActiveRecord::Base
  end

  def deterministic_key
    ActiveRecord::Encryption.config.deterministic_key
  end

  def up
    User.find_each do |user|
     user.name = ActiveRecord::Encryption::Encryptor.new.encrypt(user.name)

      user.email = ActiveRecord::Encryption::Encryptor.new.encrypt(
        user.email,
        key_provider: ActiveRecord::Encryption::DeterministicKeyProvider.new(deterministic_key),
        cipher_options: { deterministic: true },
      )

      if user.unconfirmed_email.is_a?(String)
        user.unconfirmed_email = ActiveRecord::Encryption::Encryptor.new.encrypt(
          user.unconfirmed_email,
          key_provider: ActiveRecord::Encryption::DeterministicKeyProvider.new(deterministic_key),
          cipher_options: { deterministic: true },
        )
      end

      user.save!
    end
  end

  def down
    User.find_each do |user|
      user.name = ActiveRecord::Encryption::Encryptor.new.decrypt(user.name)

      user.email = ActiveRecord::Encryption::Encryptor.new.decrypt(
        user.email,
        key_provider: ActiveRecord::Encryption::DeterministicKeyProvider.new(deterministic_key),
      )

      if user.unconfirmed_email.is_a?(String)
        user.unconfirmed_email = ActiveRecord::Encryption::Encryptor.new.decrypt(
          user.unconfirmed_email,
          key_provider: ActiveRecord::Encryption::DeterministicKeyProvider.new(deterministic_key),
        )
      end

      user.save!
    end
  end
end

Update the database constraints of devise

You might want to remove the default: '' option from the email column (see #5552 Show archive.org snapshot & #5436 Show archive.org snapshot for the discussions). In case you don't use omniauth, adding a null: false constraint could improve your data integrity.

class ChangeUserContraints < ActiveRecord::Migration[7.0]
  def up
    change_column_default(:users, :email, nil)
    change_column_null(:users, :email, false)
  end

  def down
    change_column_null(:users, :email, true)
    change_column_default(:users, :email, '')
  end
end

Optional adjusting SimpleForm

In case you are using SimpleForm Show archive.org snapshot , you might notice that the inputs are not correctly detected as string anymore. This might be fixed in the future, for now declaring the type solved the issue.

    = form.input :email,
      required: true,
      autofocus: true,
-     input_html: { autocomplete: 'email' }
+     input_html: { autocomplete: 'email' },
+     as: :string
Emanuel
February 23, 2023Software engineer at makandra GmbH
Posted by Emanuel to makandra dev (2023-02-23 10:20)