Lockbox
📦 Modern encryption for Ruby and Rails
- Works with database fields, files, and strings
- Maximizes compatibility with existing code and libraries
- Makes migrating existing data and key rotation easy
- Has zero dependencies and many integrations
Learn the principles behind it, how to secure emails with Devise, and how to secure sensitive data in Rails.
Installation
Add this line to your application’s Gemfile:
gem "lockbox"
Key Generation
Generate a key
Lockbox.generate_key
Store 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.
Set the following environment variable with your key (you can use this one in development)
LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
or add it to your credentials for each environment (rails credentials:edit --environment <env>
)
lockbox:
master_key: "0000000000000000000000000000000000000000000000000000000000000000"
or create config/initializers/lockbox.rb
with something like
Lockbox.master_key = Rails.application.credentials.lockbox[:master_key]
Then follow the instructions below for the data you want to encrypt.
Database Fields
- Active Record
- Action Text
- Mongoid
Files
- Active Storage
- CarrierWave
- Shrine
- Local Files
Other
- Strings
Active Record
Create a migration with:
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :email_ciphertext, :text
end
end
Add to your model:
class User < ApplicationRecord
has_encrypted :email
end
You 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.
Multiple Fields
You can specify multiple fields in single line.
class User < ApplicationRecord
has_encrypted :email, :phone, :city
end
Types
Fields are strings by default. Specify the type of a field with:
class User < ApplicationRecord
has_encrypted :birthday, type: :date
has_encrypted :signed_at, type: :datetime
has_encrypted :opens_at, type: :time
has_encrypted :active, type: :boolean
has_encrypted :salary, type: :integer
has_encrypted :latitude, type: :float
has_encrypted :longitude, type: :decimal
has_encrypted :video, type: :binary
has_encrypted :properties, type: :json
has_encrypted :settings, type: :hash
has_encrypted :messages, type: :array
has_encrypted :ip, type: :inet
end
Note: 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
has_encrypted :properties, :settings, :configuration
end
For Active Record Store, encrypt the column rather than individual accessors.
For StoreModel, use:
class User < ApplicationRecord
has_encrypted :configuration, type: Configuration.to_type
after_initialize do
self.configuration ||= {}
end
end
Validations
Validations work as expected with the exception of uniqueness. Uniqueness validations require a blind index.
Fixtures
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.
Migrating Existing Data
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
has_encrypted :email, migrating: true
end
Backfill the data in the Rails console:
Lockbox.migrate(User)
Then update the model to the desired state:
class User < ApplicationRecord
has_encrypted :email
# remove this line after dropping email column
self.ignored_columns += ["email"]
end
Finally, 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
end
Model Changes
If tracking changes to model attributes, be sure to remove or redact encrypted attributes.
PaperTrail
class User < ApplicationRecord
# for an encrypted history (still tracks ciphertext changes)
has_paper_trail skip: [:email]
# for no history (add blind indexes as well)
has_paper_trail skip: [:email, :email_ciphertext]
end
Audited
class User < ApplicationRecord
# for an encrypted history (still tracks ciphertext changes)
audited except: [:email]
# for no history (add blind indexes as well)
audited except: [:email, :email_ciphertext]
end
Decryption
To decrypt data outside the model, use:
User.decrypt_email_ciphertext(user.email_ciphertext)
Action Text
Note: Action Text uses direct uploads for files, which cannot be encrypted with application-level encryption like Lockbox. This only encrypts the database field.
Create a migration with:
class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[8.0]
def change
add_column :action_text_rich_texts, :body_ciphertext, :text
end
end
Create 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_body
And drop the unencrypted column.
Options
You can pass any Lockbox options to the encrypts_action_text_body
method.
Mongoid
Add to your model:
class User
field :email_ciphertext, type: String
has_encrypted :email
end
You 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.
Active Storage
Add to your model:
class User < ApplicationRecord
has_one_attached :license
encrypts_attached :license
end
Works with multiple attachments as well.
class User < ApplicationRecord
has_many_attached :documents
encrypts_attached :documents
end
There are a few limitations to be aware of:
- Variants and previews aren’t supported when encrypted
- Metadata like image width and height aren’t extracted when encrypted
- Direct uploads can’t be encrypted with application-level encryption like Lockbox, but can use server-side encryption
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
end
Use filename
to specify a filename or disposition: "inline"
to show inline.
Migrating Existing Files
Lockbox makes it easy to encrypt existing files without downtime.
Add to your model:
class User < ApplicationRecord
encrypts_attached :license, migrating: true
end
Migrate existing files:
Lockbox.migrate(User)
Then update the model to the desired state:
class User < ApplicationRecord
encrypts_attached :license
end
CarrierWave
Add to your uploader:
class LicenseUploader < CarrierWave::Uploader::Base
encrypt
end
Encryption 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[8.0]
def change
add_column :users, :license, :string
end
end
And updating the model:
class User < ApplicationRecord
mount_uploader :license, LicenseUploader
end
To serve encrypted files, use a controller action.
def license
user = User.find(params[:id])
send_data user.license.read, type: user.license.content_type
end
Use filename
to specify a filename or disposition: "inline"
to show inline.
Migrating Existing Files
Encrypt existing files without downtime. Create a new encrypted uploader:
class LicenseV2Uploader < CarrierWave::Uploader::Base
encrypt key: Lockbox.attribute_key(table: "users", attribute: "license")
end
Add 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
end
Migrate existing files:
User.find_each do |user|
if user.license? && !user.license_v2?
user.migrate_license
user.save!
end
end
Then update the model to the desired state:
class User < ApplicationRecord
mount_uploader :license, LicenseV2Uploader, mount_on: :license_v2
end
Finally, delete the unencrypted files and drop the column for the original uploader. You can also remove the key
option from the uploader.
Shrine
Models
Include the attachment as normal:
class User < ApplicationRecord
include LicenseUploader::Attachment(:license)
end
And encrypt in a controller (or background job, etc) with:
license = params.require(:user).fetch(:license)
lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
user.license = lockbox.encrypt_io(license)
To serve encrypted files, use a controller action.
def license
user = User.find(params[:id])
lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
send_data lockbox.decrypt(user.license.read), type: user.license.mime_type
end
Use filename
to specify a filename or disposition: "inline"
to show inline.
Non-Models
Generate a key
key = Lockbox.generate_key
Create 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)
Local Files
Generate a key
key = Lockbox.generate_key
Create a lockbox
lockbox = Lockbox.new(key: key)
Encrypt
ciphertext = lockbox.encrypt(File.binread("file.txt"))
Decrypt
lockbox.decrypt(ciphertext)
Strings
Generate a key
key = Lockbox.generate_key
Create 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
Key Rotation
To make key rotation easy, you can pass previous versions of keys that can decrypt.
Create config/initializers/lockbox.rb
with:
Lockbox.default_options[:previous_versions] = [{master_key: previous_key}]
To rotate existing Active Record & Mongoid records, use:
Lockbox.rotate(User, attributes: [:email])
To rotate existing Action Text records, use:
Lockbox.rotate(ActionText::RichText, attributes: [:body])
To rotate existing Active Storage files, use:
User.with_attached_license.find_each do |user|
user.license.rotate_encryption!
end
To rotate existing CarrierWave files, use:
User.find_each do |user|
user.license.rotate_encryption!
# or for multiple files
user.licenses.map(&:rotate_encryption!)
end
Once everything is rotated, you can remove previous_versions
from the initializer.
Individual Fields & Files
You can also pass previous versions to individual fields and files.
class User < ApplicationRecord
has_encrypted :email, previous_versions: [{master_key: previous_key}]
end
Local Files & Strings
To rotate local files and strings, use:
Lockbox.new(key: key, previous_versions: [{key: previous_key}])
Auditing
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:migrate
Then 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
end
Query 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.
Algorithms
AES-GCM
This is the default algorithm. It’s:
- well-studied
- NIST recommended
- an IETF standard
- fast thanks to a dedicated instruction set
Lockbox uses 256-bit keys.
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 authentication key. Each database field and file uploader use a different key (derived from the master key) to extend this window.
XSalsa20
You can also use XSalsa20, which uses an extended nonce so you don’t have to worry about nonce collisions. First, install Libsodium. It comes preinstalled on Heroku. For Homebrew, use:
brew install libsodium
And for Ubuntu, use:
sudo apt-get install libsodium23
Then add to your Gemfile:
gem "rbnacl"
And add to your model:
class User < ApplicationRecord
has_encrypted :email, algorithm: "xsalsa20"
end
Make it the default with:
Lockbox.default_options[:algorithm] = "xsalsa20"
You can also pass an algorithm to previous_versions
for key rotation.
Hybrid Cryptography
Hybrid 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_pair
Store the keys with your other secrets. Then use:
class User < ApplicationRecord
has_encrypted :email, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
end
Make sure decryption_key
is nil
on servers that shouldn’t decrypt.
This uses X25519 for key exchange and XSalsa20 for encryption.
Key Configuration
Lockbox supports a few different ways to set keys for database fields and files.
- Master key
- Per field/uploader
- Per record
Master Key
By default, the master key is used to generate unique keys for each field/uploader. This technique comes from CipherSweet. The table name and column/uploader name are both used in this process.
You can get an individual key with:
Lockbox.attribute_key(table: "users", attribute: "email_ciphertext")
To rename a table with encrypted columns/uploaders, use:
class User < ApplicationRecord
has_encrypted :email, key_table: "original_table"
end
To rename an encrypted column itself, use:
class User < ApplicationRecord
has_encrypted :email, key_attribute: "original_column"
end
Per Field/Uploader
To set a key for an individual field/uploader, use a string:
class User < ApplicationRecord
has_encrypted :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"]
end
Or a proc:
class User < ApplicationRecord
has_encrypted :email, key: -> { code }
end
Per Record
To use a different key for each record, use a symbol:
class User < ApplicationRecord
has_encrypted :email, key: :some_method
end
Or a proc:
class User < ApplicationRecord
has_encrypted :email, key: -> { some_method }
end
Key Management
You can use a key management service to manage your keys with KMS Encrypted.
For Active Record and Mongoid, use:
class User < ApplicationRecord
has_encrypted :email, key: :kms_key
end
For Action Text, use:
ActiveSupport.on_load(:action_text_rich_text) do
ActionText::RichText.has_kms_key
end
Lockbox.encrypts_action_text_body(key: :kms_key)
For Active Storage, use:
class User < ApplicationRecord
encrypts_attached :license, key: :kms_key
end
For CarrierWave, use:
class LicenseUploader < CarrierWave::Uploader::Base
encrypt key: -> { model.kms_key }
end
Note: 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.
Data Leakage
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 # 36
Add 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 # 44
The block size for padding is 16 bytes by default. Lockbox uses ISO/IEC 7816-4 padding, which uses at least one byte, so 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 # 60
Change the block size with:
Lockbox.new(padding: 32) # bytes
Associated Data
You can pass extra context during encryption to make sure encrypted data isn’t moved to a different context.
lockbox = Lockbox.new(key: key)
ciphertext = lockbox.encrypt(message, associated_data: "somecontext")
Without the same context, decryption will fail.
lockbox.decrypt(ciphertext, associated_data: "somecontext") # success
lockbox.decrypt(ciphertext, associated_data: "othercontext") # fails
You can also use it with database fields and files.
class User < ApplicationRecord
has_encrypted :email, associated_data: -> { code }
end
Binary Columns
You can use binary
columns for the ciphertext instead of text
columns.
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :email_ciphertext, :binary
end
end
Disable Base64 encoding to save space.
class User < ApplicationRecord
has_encrypted :email, encode: false
end
or set it globally:
Lockbox.encode_attributes = false
Compatibility
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.
Migrating from Another 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
end
Create a migration with:
class MigrateToLockbox < ActiveRecord::Migration[8.0]
def change
add_column :users, :name_ciphertext, :text
add_column :users, :email_ciphertext, :text
end
end
And add has_encrypted
to your model with the migrating
option:
class User < ApplicationRecord
has_encrypted :name, :email, migrating: true
end
Then 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
has_encrypted :name, :email
end
Then remove the previous gem from your Gemfile and drop its columns.
class RemovePreviousEncryptedColumns < ActiveRecord::Migration[8.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
end
History
View the changelog
Contributing
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 test
For security issues, send an email to the address on this page.