📦 Modern encryption for Rails
- Uses state-of-the-art algorithms
- Works with database fields, files, and strings
- Makes migrating existing data and key rotation easy
Lockbox aims to make encryption as friendly and intuitive as possible. Encrypted fields and files behave just like unencrypted ones for maximum compatibility with 3rd party libraries and existing code.
Learn the principles behind it, how to secure emails with Devise, and how to secure sensitive data in Rails.
Add this line to your application’s Gemfile:
gem 'lockbox'Generate a key
Lockbox.generate_keyStore the key with your other secrets. This is typically Rails credentials or an environment variable (dotenv is great for this). Be sure to use different keys in development and production. Keys don’t need to be hex-encoded, but it’s often easier to store them this way.
Set the following environment variable with your key (you can use this one in development)
LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000or create config/initializers/lockbox.rb with something like
Lockbox.master_key = Rails.application.credentials.lockbox_master_keyThen follow the instructions below for the data you want to encrypt.
Create a migration with:
class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :email_ciphertext, :text
end
endAdd to your model:
class User < ApplicationRecord
encrypts :email
endYou can use email just like any other attribute.
User.create!(email: "hi@example.org")If you need to query encrypted fields, check out Blind Index.
Fields are strings by default. Specify the type of a field with:
class User < ApplicationRecord
encrypts :born_on, type: :date
encrypts :signed_at, type: :datetime
encrypts :opens_at, type: :time
encrypts :active, type: :boolean
encrypts :salary, type: :integer
encrypts :latitude, type: :float
encrypts :video, type: :binary
encrypts :properties, type: :json
encrypts :settings, type: :hash
encrypts :messages, type: :array
endNote: Use a text column for the ciphertext in migrations, regardless of the type
Lockbox automatically works with serialized fields for maximum compatibility with existing code and libraries.
class User < ApplicationRecord
serialize :properties, JSON
store :settings, accessors: [:color, :homepage]
attribute :configuration, CustomType.new
encrypts :properties, :settings, :configuration
endFor StoreModel, use:
class User < ApplicationRecord
encrypts :configuration, type: Configuration.to_type
after_initialize do
self.configuration ||= {}
end
endValidations work as expected with the exception of uniqueness. Uniqueness validations require a blind index.
You can use encrypted attributes in fixtures with:
test_user:
email_ciphertext: <%= User.generate_email_ciphertext("secret").inspect %>Be sure to include the inspect at the end or it won’t be encoded properly in YAML.
Lockbox makes it easy to encrypt an existing column without downtime.
Add a new column for the ciphertext, then add to your model:
class User < ApplicationRecord
encrypts :email, migrating: true
endBackfill the data in the Rails console:
Lockbox.migrate(User)Then update the model to the desired state:
class User < ApplicationRecord
encrypts :email
# remove this line after dropping email column
self.ignored_columns = ["email"]
endFinally, drop the unencrypted column.
If adding blind indexes, mark them as migrating during this process as well.
class User < ApplicationRecord
blind_index :email, migrating: true
endCreate a migration with:
class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[6.0]
def change
add_column :action_text_rich_texts, :body_ciphertext, :text
end
endCreate config/initializers/lockbox.rb with:
Lockbox.encrypts_action_text_body(migrating: true)Migrate existing data:
Lockbox.migrate(ActionText::RichText)Update the initializer:
Lockbox.encrypts_action_text_bodyAnd drop the unencrypted column.
Add to your model:
class User
field :email_ciphertext, type: String
encrypts :email
endYou can use email just like any other attribute.
User.create!(email: "hi@example.org")If you need to query encrypted fields, check out Blind Index.
You can migrate existing data similarly to Active Record.
Add to your model:
class User < ApplicationRecord
has_one_attached :license
encrypts_attached :license
endWorks with multiple attachments as well.
class User < ApplicationRecord
has_many_attached :documents
encrypts_attached :documents
endThere are a few limitations to be aware of:
- Metadata like image width and height are not extracted when encrypted
- Direct uploads cannot be encrypted
To serve encrypted files, use a controller action.
def license
user = User.find(params[:id])
send_data user.license.download, type: user.license.content_type
endNote: This feature is experimental. Please try it in a non-production environment and share how it goes.
Lockbox makes it easy to encrypt existing files without downtime.
Add to your model:
class User < ApplicationRecord
encrypts_attached :license, migrating: true
endMigrate existing files:
Lockbox.migrate(User)Then update the model to the desired state:
class User < ApplicationRecord
encrypts_attached :license
endAdd to your uploader:
class LicenseUploader < CarrierWave::Uploader::Base
encrypt
endEncryption is applied to all versions after processing.
You can mount the uploader as normal. With Active Record, this involves creating a migration:
class AddLicenseToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :license, :string
end
endAnd updating the model:
class User < ApplicationRecord
mount_uploader :license, LicenseUploader
endTo serve encrypted files, use a controller action.
def license
user = User.find(params[:id])
send_data user.license.read, type: user.license.content_type
endEncrypt existing files without downtime. Create a new encrypted uploader:
class LicenseV2Uploader < CarrierWave::Uploader::Base
encrypt key: Lockbox.attribute_key(table: "users", attribute: "license")
endAdd a new column for the uploader, then add to your model:
class User < ApplicationRecord
mount_uploader :license_v2, LicenseV2Uploader
before_save :migrate_license, if: :license_changed?
def migrate_license
self.license_v2 = license
end
endMigrate existing files:
User.find_each do |user|
if user.license? && !user.license_v2?
user.migrate_license
user.save!
end
endThen update the model to the desired state:
class User < ApplicationRecord
mount_uploader :license, LicenseV2Uploader, mount_on: :license_v2
endFinally, delete the unencrypted files and drop the column for the original uploader. You can also remove the key option from the uploader.
Generate a key
key = Lockbox.generate_keyCreate a lockbox
lockbox = Lockbox.new(key: key)Encrypt files before passing them to Shrine
LicenseUploader.upload(lockbox.encrypt_io(file), :store)And decrypt them after reading
lockbox.decrypt(uploaded_file.read)For models, encrypt with:
license = params.require(:user).fetch(:license)
user.license = lockbox.encrypt_io(license)To serve encrypted files, use a controller action.
def license
user = User.find(params[:id])
send_data lockbox.decrypt(user.license.read), type: user.license.mime_type
endGenerate a key
key = Lockbox.generate_keyCreate a lockbox
lockbox = Lockbox.new(key: key)Encrypt
ciphertext = lockbox.encrypt(File.binread("file.txt"))Decrypt
lockbox.decrypt(ciphertext)Generate a key
key = Lockbox.generate_keyCreate a lockbox
lockbox = Lockbox.new(key: key, encode: true)Encrypt
ciphertext = lockbox.encrypt("hello")Decrypt
lockbox.decrypt(ciphertext)Use decrypt_str get the value as UTF-8
To make key rotation easy, you can pass previous versions of keys that can decrypt.
Update your model:
class User < ApplicationRecord
encrypts :email, previous_versions: [{key: previous_key}]
endUse master_key instead of key if passing the master key.
To rotate existing records, use:
Lockbox.rotate(User, attributes: [:email])Once all records are rotated, you can remove previous_versions from the model.
Update your model:
class User < ApplicationRecord
encrypts_attached :license, previous_versions: [{key: previous_key}]
endUse master_key instead of key if passing the master key.
To rotate existing files, use:
User.with_attached_license.find_each do |user|
user.license.rotate_encryption!
endOnce all files are rotated, you can remove previous_versions from the model.
Update your model:
class LicenseUploader < CarrierWave::Uploader::Base
encrypt previous_versions: [{key: previous_key}]
endUse master_key instead of key if passing the master key.
To rotate existing files, use:
User.find_each do |user|
user.license.rotate_encryption!
endOnce all files are rotated, you can remove previous_versions from the model.
For local files and strings, use:
Lockbox.new(key: key, previous_versions: [{key: previous_key}])It’s a good idea to track user and employee access to sensitive data. Lockbox provides a convenient way to do this with Active Record, but you can use a similar pattern to write audits to any location.
rails generate lockbox:audits
rails db:migrateThen create an audit wherever a user can view data:
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
LockboxAudit.create!(
subject: @user,
viewer: current_user,
data: ["name", "email"],
context: "#{controller_name}##{action_name}",
ip: request.remote_ip
)
end
endQuery audits with:
LockboxAudit.last(100)Note: This approach is not intended to be used in the event of a breach or insider attack, as it’s trivial for someone with access to your infrastructure to bypass.
This is the default algorithm. It’s:
- well-studied
- NIST recommended
- an IETF standard
- fast thanks to a dedicated instruction set
For users who do a lot of encryptions: You should rotate an individual key after 2 billion encryptions to minimize the chance of a nonce collision, which will expose the key. Each database field and file uploader use a different key (derived from the master key) to extend this window.
You can also use XSalsa20, which uses an extended nonce so you don’t have to worry about nonce collisions. First, install Libsodium. For Homebrew, use:
brew install libsodiumAnd add to your Gemfile:
gem 'rbnacl'Then add to your model:
class User < ApplicationRecord
encrypts :email, algorithm: "xsalsa20"
endMake it the default with:
Lockbox.default_options = {algorithm: "xsalsa20"}You can also pass an algorithm to previous_versions for key rotation.
Heroku comes with libsodium preinstalled.
For Ubuntu 20.04 and 18.04, use:
sudo apt-get install libsodium23For Ubuntu 16.04, use:
sudo apt-get install libsodium18On Bionic, add to .travis.yml:
addons:
apt:
packages:
- libsodium23On Xenial, add to .travis.yml:
addons:
apt:
packages:
- libsodium18Add a step to .circleci/config.yml:
- run:
name: install Libsodium
command: |
sudo apt-get install -y libsodium18Hybrid cryptography allows servers to encrypt data without being able to decrypt it.
Follow the instructions above for installing Libsodium and including rbnacl in your Gemfile.
Generate a key pair with:
Lockbox.generate_key_pairStore the keys with your other secrets. Then use:
class User < ApplicationRecord
encrypts :email, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
endMake sure decryption_key is nil on servers that shouldn’t decrypt.
This uses X25519 for key exchange and XSalsa20 for encryption.
The master key is used to generate unique keys for each column. This technique comes from CipherSweet. The table name and column name are both used in this process. If you need to rename a table with encrypted columns, or an encrypted column itself, get the key:
Lockbox.attribute_key(table: "users", attribute: "email_ciphertext")And set it directly before renaming:
class User < ApplicationRecord
encrypts :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"]
endYou can use a key management service to manage your keys with KMS Encrypted.
class User < ApplicationRecord
encrypts :email, key: :kms_key
endFor CarrierWave, use:
class LicenseUploader < CarrierWave::Uploader::Base
encrypt key: -> { model.kms_key }
endNote: KMS Encrypted’s key rotation does not know to rotate encrypted files, so avoid calling record.rotate_kms_key! on models with file uploads for now.
While encryption hides the content of a message, an attacker can still get the length of the message (since the length of the ciphertext is the length of the message plus a constant number of bytes).
Let’s say you want to encrypt the status of a candidate’s background check. Valid statuses are clear, consider, and fail. Even with the data encrypted, it’s trivial to map the ciphertext to a status.
lockbox = Lockbox.new(key: key)
lockbox.encrypt("fail").bytesize # 32
lockbox.encrypt("clear").bytesize # 33
lockbox.encrypt("consider").bytesize # 36Add padding to conceal the exact length of messages.
lockbox = Lockbox.new(key: key, padding: true)
lockbox.encrypt("fail").bytesize # 44
lockbox.encrypt("clear").bytesize # 44
lockbox.encrypt("consider").bytesize # 44The block size for padding is 16 bytes by default. If we have a status larger than 15 bytes, it will have a different length than the others.
box.encrypt("length15status!").bytesize # 44
box.encrypt("length16status!!").bytesize # 60Change the block size with:
Lockbox.new(padding: 32) # bytesYou can use binary columns for the ciphertext instead of text columns to save space.
class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :email_ciphertext, :binary
end
endYou should disable Base64 encoding if you do this.
class User < ApplicationRecord
encrypts :email, encode: false
endor set it globally:
Lockbox.default_options = {encode: false}It’s easy to read encrypted data in another language if needed.
For AES-GCM, the format is:
- nonce (IV) - 12 bytes
- ciphertext - variable length
- authentication tag - 16 bytes
Here are some examples.
For XSalsa20, use the appropriate Libsodium library.
Lockbox makes it easy to migrate from another library without downtime. The example below uses attr_encrypted but the same approach should work for any library.
Let’s suppose your model looks like this:
class User < ApplicationRecord
attr_encrypted :name, key: key
attr_encrypted :email, key: key
endCreate a migration with:
class MigrateToLockbox < ActiveRecord::Migration[6.0]
def change
add_column :users, :name_ciphertext, :text
add_column :users, :email_ciphertext, :text
end
endAnd add encrypts to your model with the migrating option:
class User < ApplicationRecord
encrypts :name, :email, migrating: true
endThen run:
Lockbox.migrate(User)Once all records are migrated, remove the migrating option and the previous model code (the attr_encrypted methods in this example).
class User < ApplicationRecord
encrypts :name, :email
endThen remove the previous gem from your Gemfile and drop its columns.
class RemovePreviousEncryptedColumns < ActiveRecord::Migration[6.0]
def change
remove_column :users, :encrypted_name, :text
remove_column :users, :encrypted_name_iv, :text
remove_column :users, :encrypted_email, :text
remove_column :users, :encrypted_email_iv, :text
end
end0.3.6 makes content type detection more reliable for Active Storage. You can check and update the content type of existing files with:
User.find_each do |user|
license = user.license
content_type = Marcel::MimeType.for(license.download, name: license.filename.to_s)
if content_type != license.content_type
license.update!(content_type: content_type)
end
end0.2.0 brings a number of improvements. Here are a few to be aware of:
- Added
encryptsmethod for database fields - Added support for XSalsa20
attached_encryptedis deprecated in favor ofencrypts_attached.
To switch to a master key, generate a key:
Lockbox.generate_keyAnd set ENV["LOCKBOX_MASTER_KEY"] or Lockbox.master_key.
Update your model:
class User < ApplicationRecord
encrypts_attached :license, previous_versions: [{key: key}]
endNew uploads will be encrypted with the new key.
You can rotate existing records with:
User.unscoped.find_each do |user|
user.license.rotate_encryption!
endOnce that’s complete, update your model:
class User < ApplicationRecord
encrypts_attached :license
endView the changelog
Everyone is encouraged to help improve this project. Here are a few ways you can help:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features
To get started with development, install Libsodium and run:
git clone https://github.com/ankane/lockbox.git
cd lockbox
bundle install
bundle exec rake testFor security issues, send an email to the address on this page.