Mastering Rails Encryption: Safeguarding User Data with ActiveSupport::MessageEncryptor

Rails provides powerful encryption tools. Use ActiveSupport::MessageEncryptor to secure sensitive data. Implement a flexible Encryptable module for automatic encryption/decryption. Consider performance, key rotation, and testing strategies when working with encrypted fields.

Mastering Rails Encryption: Safeguarding User Data with ActiveSupport::MessageEncryptor

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,