Securing user data is crucial for any web application, and Rails provides some powerful built-in tools to help us encrypt sensitive information. Let’s dive into using ActiveSupport::MessageEncryptor to keep our users’ data safe and sound.
First things first, we need to set up our encryption key. In Rails, we can use the credentials system to store this securely. Open your credentials file with:
rails credentials:edit
Add a new key for your encryption:
encryption_key: your_long_random_string_here
Now, let’s create a module to handle our encryption and decryption:
# app/models/concerns/encryptable.rb
module Encryptable
extend ActiveSupport::Concern
included do
before_save :encrypt_sensitive_data
after_find :decrypt_sensitive_data
end
private
def encrypt_sensitive_data
encryptor.encrypt_and_sign(self.sensitive_field)
end
def decrypt_sensitive_data
self.sensitive_field = encryptor.decrypt_and_verify(self.sensitive_field)
end
def encryptor
key = Rails.application.credentials.encryption_key
ActiveSupport::MessageEncryptor.new(key)
end
end
This module can be included in any model where we want to encrypt data. It automatically encrypts the data before saving and decrypts it when the record is loaded.
Let’s say we have a User model with a sensitive_field that we want to encrypt:
class User < ApplicationRecord
include Encryptable
# other user model code...
end
Now, whenever we save or load a User record, the sensitive_field will be automatically encrypted and decrypted.
But wait, there’s more! What if we want to encrypt multiple fields? We can make our Encryptable module more flexible:
module Encryptable
extend ActiveSupport::Concern
class_methods do
def encrypt_fields(*fields)
@encrypted_fields = fields
end
end
included do
before_save :encrypt_sensitive_data
after_find :decrypt_sensitive_data
end
private
def encrypt_sensitive_data
self.class.instance_variable_get(:@encrypted_fields).each do |field|
self[field] = encryptor.encrypt_and_sign(self[field]) if self[field].present?
end
end
def decrypt_sensitive_data
self.class.instance_variable_get(:@encrypted_fields).each do |field|
self[field] = encryptor.decrypt_and_verify(self[field]) if self[field].present?
end
end
def encryptor
key = Rails.application.credentials.encryption_key
ActiveSupport::MessageEncryptor.new(key)
end
end
Now we can specify which fields we want to encrypt in our model:
class User < ApplicationRecord
include Encryptable
encrypt_fields :email, :phone_number, :credit_card
# other user model code...
end
This approach gives us more flexibility and keeps our code DRY. We can easily add or remove fields from the encryption list without modifying the Encryptable module.
But what about searching? Encrypted data can’t be easily searched, so we might want to keep a searchable version of some fields. Let’s update our module to handle this:
module Encryptable
extend ActiveSupport::Concern
class_methods do
def encrypt_fields(*fields)
@encrypted_fields = fields
end
def searchable_encrypted_fields(*fields)
@searchable_encrypted_fields = fields
end
end
included do
before_save :encrypt_sensitive_data
after_find :decrypt_sensitive_data
end
private
def encrypt_sensitive_data
self.class.instance_variable_get(:@encrypted_fields).each do |field|
if self[field].present?
self[field] = encryptor.encrypt_and_sign(self[field])
if self.class.instance_variable_get(:@searchable_encrypted_fields)&.include?(field)
self["searchable_#{field}"] = self[field].downcase
end
end
end
end
def decrypt_sensitive_data
self.class.instance_variable_get(:@encrypted_fields).each do |field|
self[field] = encryptor.decrypt_and_verify(self[field]) if self[field].present?
end
end
def encryptor
key = Rails.application.credentials.encryption_key
ActiveSupport::MessageEncryptor.new(key)
end
end
And in our model:
class User < ApplicationRecord
include Encryptable
encrypt_fields :email, :phone_number, :credit_card
searchable_encrypted_fields :email
# other user model code...
end
Now, when we save a user, it will create a searchable_email field with the lowercase version of the email, allowing us to search for users by email while still keeping the actual email encrypted.
But hold on, what about performance? Encrypting and decrypting data on every save and load operation can be slow, especially for large datasets. We can optimize this by using Rails’ attribute API to create virtual attributes for our encrypted fields:
module Encryptable
extend ActiveSupport::Concern
class_methods do
def encrypt_field(name)
attribute name, :string
define_method(name) do
value = read_attribute("encrypted_#{name}")
value.present? ? encryptor.decrypt_and_verify(value) : nil
end
define_method("#{name}=") do |value|
write_attribute("encrypted_#{name}", value.present? ? encryptor.encrypt_and_sign(value) : nil)
end
end
end
private
def encryptor
key = Rails.application.credentials.encryption_key
ActiveSupport::MessageEncryptor.new(key)
end
end
Now in our model:
class User < ApplicationRecord
include Encryptable
encrypt_field :email
encrypt_field :phone_number
encrypt_field :credit_card
# other user model code...
end
This approach lazily encrypts and decrypts data only when it’s accessed, which can significantly improve performance.
But what about data integrity? How can we ensure that our encrypted data hasn’t been tampered with? ActiveSupport::MessageEncryptor actually handles this for us by using authenticated encryption. It not only encrypts the data but also signs it, ensuring that any tampering will be detected when decrypting.
However, we should also consider the possibility of our encryption key being compromised. To mitigate this risk, we can use key rotation. Let’s update our Encryptable module to support this:
module Encryptable
extend ActiveSupport::Concern
class_methods do
def encrypt_field(name)
attribute name, :string
define_method(name) do
value = read_attribute("encrypted_#{name}")
value.present? ? decrypt_value(value) : nil
end
define_method("#{name}=") do |value|
write_attribute("encrypted_#{name}", value.present? ? encrypt_value(value) : nil)
end
end
end
private
def encrypt_value(value)
encryptor.encrypt_and_sign(value)
end
def decrypt_value(value)
current_encryptor.decrypt_and_verify(value)
rescue ActiveSupport::MessageEncryptor::InvalidMessage
old_encryptor.decrypt_and_verify(value)
end
def encryptor
@encryptor ||= ActiveSupport::MessageEncryptor.new(current_key)
end
def old_encryptor
@old_encryptor ||= ActiveSupport::MessageEncryptor.new(old_key)
end
def current_key
Rails.application.credentials.current_encryption_key
end
def old_key
Rails.application.credentials.old_encryption_key
end
end
In this updated version, we try to decrypt with the current key first, and if that fails, we fall back to the old key. This allows us to rotate keys without breaking existing encrypted data.
To use this, we’d update our credentials file:
current_encryption_key: your_new_long_random_string_here
old_encryption_key: your_old_long_random_string_here
When rotating keys, we’d move the current key to old_key and generate a new current_key. Then, we’d need to re-encrypt all data with the new key, which could be done with a background job or migration.
Speaking of migrations, what if we want to add encryption to an existing field? We can create a migration to encrypt the data:
class EncryptUserEmails < ActiveRecord::Migration[6.1]
def up
User.find_each do |user|
user.email = user.read_attribute(:email)
user.save!
end
end
def down
User.find_each do |user|
user.write_attribute(:email, user.email)
user.save!
end
end
end
This migration uses our Encryptable module to encrypt all existing emails. The down method allows us to revert if needed.
Now, let’s talk about testing. When working with encrypted data, we need to ensure our tests are still effective. Here’s an example of how we might test our User model:
require 'rails_helper'
RSpec.describe User, type: :model do
describe 'encryption' do
it 'encrypts sensitive fields' do
user = User.create(email: '[email protected]', phone_number: '1234567890')
expect(user.read_attribute(:encrypted_email)).not_to eq '[email protected]'
expect(user.read_attribute(:encrypted_phone_number)).not_to eq '1234567890'
end
it 'decrypts sensitive fields' do
user = User.create(email: '[email protected]', phone_number: '1234567890')
user.reload
expect(user.email).to eq '[email protected]'
expect(user.phone_number).to eq '1234567890'
end
end
end
These tests ensure that our sensitive data is being encrypted when saved and decrypted when accessed.
But what about performance in our tests? Encryption and decryption can slow down our test suite. We might want to disable encryption in our test environment:
# config/environments/test.rb
config.encryption_enabled = false
# app/models/concerns/encryptable.rb
module Encryptable
# ... other code ...
private
def encrypt_value(value)
Rails.configuration.encryption_enabled ? encryptor.encrypt_and_sign(value) : value
end
def decrypt_value(value)
Rails.configuration.encryption_enabled ? current_encryptor.decrypt_and_verify(value) : value
end
# ... other code ...
end
This allows our tests to run faster while still allowing us to explicitly test encryption when needed.
Lastly, let’s consider the user experience. When working with encrypted data, we need to be careful about how we display it. For example, we might want to partially mask sensitive information:
class User < ApplicationRecord
include Encryptable
encrypt_field :email
encrypt_field :phone_number
encrypt_field :credit_card
def masked_credit_card
"xxxx-xxxx-xxxx-#{credit_card[-4..-1]}"
end
def masked_email
email.gsub(/(?<=.{3}).*(?=@)/, '****')
end
# other user model code...
end
These methods allow us to display sensitive information in a user-friendly way without exposing the full data.
In conclusion, securing sensitive user data with encryption in Rails is a crucial aspect of building secure applications. By leveraging ActiveSupport::MessageEncryptor and creating flexible,