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.
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
anddeterministic
mode. Thenon-deterministic
mode is the default and will result in a different payload for the same value. In case you need aunique
index e.g. for aemail
attribute, you need to use thedeterministic
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 fordeterministic
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