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 online protection

Rails Long Term Support

Rails LTS provides security patches for old versions of Ruby on Rails (2.3, 3.2, 4.2 and 5.2)

  • Prevents you from data breaches and liability risks
  • Upgrade at your own pace
  • Works with modern Rubies
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)