项目作者: ankane

项目描述 :
Modern encryption for Ruby and Rails
高级语言: Ruby
项目地址: git://github.com/ankane/lockbox.git
创建时间: 2019-01-02T19:21:24Z
项目社区:https://github.com/ankane/lockbox

开源协议:MIT License

下载


Lockbox

:package: 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.

Build Status

Installation

Add this line to your application’s Gemfile:

  1. gem "lockbox"

Key Generation

Generate a key

  1. 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)

  1. LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000

or add it to your credentials for each environment (rails credentials:edit --environment <env>)

  1. lockbox:
  2. master_key: "0000000000000000000000000000000000000000000000000000000000000000"

or create config/initializers/lockbox.rb with something like

  1. Lockbox.master_key = Rails.application.credentials.lockbox[:master_key]

Then follow the instructions below for the data you want to encrypt.

Database Fields

Files

Other

Active Record

Create a migration with:

  1. class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.0]
  2. def change
  3. add_column :users, :email_ciphertext, :text
  4. end
  5. end

Add to your model:

  1. class User < ApplicationRecord
  2. has_encrypted :email
  3. end

You can use email just like any other attribute.

  1. 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.

  1. class User < ApplicationRecord
  2. has_encrypted :email, :phone, :city
  3. end

Types

Fields are strings by default. Specify the type of a field with:

  1. class User < ApplicationRecord
  2. has_encrypted :birthday, type: :date
  3. has_encrypted :signed_at, type: :datetime
  4. has_encrypted :opens_at, type: :time
  5. has_encrypted :active, type: :boolean
  6. has_encrypted :salary, type: :integer
  7. has_encrypted :latitude, type: :float
  8. has_encrypted :longitude, type: :decimal
  9. has_encrypted :video, type: :binary
  10. has_encrypted :properties, type: :json
  11. has_encrypted :settings, type: :hash
  12. has_encrypted :messages, type: :array
  13. has_encrypted :ip, type: :inet
  14. 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.

  1. class User < ApplicationRecord
  2. serialize :properties, JSON
  3. store :settings, accessors: [:color, :homepage]
  4. attribute :configuration, CustomType.new
  5. has_encrypted :properties, :settings, :configuration
  6. end

For Active Record Store, encrypt the column rather than individual accessors.

For StoreModel, use:

  1. class User < ApplicationRecord
  2. has_encrypted :configuration, type: Configuration.to_type
  3. after_initialize do
  4. self.configuration ||= {}
  5. end
  6. 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:

  1. test_user:
  2. 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:

  1. class User < ApplicationRecord
  2. has_encrypted :email, migrating: true
  3. end

Backfill the data in the Rails console:

  1. Lockbox.migrate(User)

Then update the model to the desired state:

  1. class User < ApplicationRecord
  2. has_encrypted :email
  3. # remove this line after dropping email column
  4. self.ignored_columns += ["email"]
  5. end

Finally, drop the unencrypted column.

If adding blind indexes, mark them as migrating during this process as well.

  1. class User < ApplicationRecord
  2. blind_index :email, migrating: true
  3. end

Model Changes

If tracking changes to model attributes, be sure to remove or redact encrypted attributes.

PaperTrail

  1. class User < ApplicationRecord
  2. # for an encrypted history (still tracks ciphertext changes)
  3. has_paper_trail skip: [:email]
  4. # for no history (add blind indexes as well)
  5. has_paper_trail skip: [:email, :email_ciphertext]
  6. end

Audited

  1. class User < ApplicationRecord
  2. # for an encrypted history (still tracks ciphertext changes)
  3. audited except: [:email]
  4. # for no history (add blind indexes as well)
  5. audited except: [:email, :email_ciphertext]
  6. end

Decryption

To decrypt data outside the model, use:

  1. 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:

  1. class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[8.0]
  2. def change
  3. add_column :action_text_rich_texts, :body_ciphertext, :text
  4. end
  5. end

Create config/initializers/lockbox.rb with:

  1. Lockbox.encrypts_action_text_body(migrating: true)

Migrate existing data:

  1. Lockbox.migrate(ActionText::RichText)

Update the initializer:

  1. 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:

  1. class User
  2. field :email_ciphertext, type: String
  3. has_encrypted :email
  4. end

You can use email just like any other attribute.

  1. 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:

  1. class User < ApplicationRecord
  2. has_one_attached :license
  3. encrypts_attached :license
  4. end

Works with multiple attachments as well.

  1. class User < ApplicationRecord
  2. has_many_attached :documents
  3. encrypts_attached :documents
  4. 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.

  1. def license
  2. user = User.find(params[:id])
  3. send_data user.license.download, type: user.license.content_type
  4. 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:

  1. class User < ApplicationRecord
  2. encrypts_attached :license, migrating: true
  3. end

Migrate existing files:

  1. Lockbox.migrate(User)

Then update the model to the desired state:

  1. class User < ApplicationRecord
  2. encrypts_attached :license
  3. end

CarrierWave

Add to your uploader:

  1. class LicenseUploader < CarrierWave::Uploader::Base
  2. encrypt
  3. end

Encryption is applied to all versions after processing.

You can mount the uploader as normal. With Active Record, this involves creating a migration:

  1. class AddLicenseToUsers < ActiveRecord::Migration[8.0]
  2. def change
  3. add_column :users, :license, :string
  4. end
  5. end

And updating the model:

  1. class User < ApplicationRecord
  2. mount_uploader :license, LicenseUploader
  3. end

To serve encrypted files, use a controller action.

  1. def license
  2. user = User.find(params[:id])
  3. send_data user.license.read, type: user.license.content_type
  4. 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:

  1. class LicenseV2Uploader < CarrierWave::Uploader::Base
  2. encrypt key: Lockbox.attribute_key(table: "users", attribute: "license")
  3. end

Add a new column for the uploader, then add to your model:

  1. class User < ApplicationRecord
  2. mount_uploader :license_v2, LicenseV2Uploader
  3. before_save :migrate_license, if: :license_changed?
  4. def migrate_license
  5. self.license_v2 = license
  6. end
  7. end

Migrate existing files:

  1. User.find_each do |user|
  2. if user.license? && !user.license_v2?
  3. user.migrate_license
  4. user.save!
  5. end
  6. end

Then update the model to the desired state:

  1. class User < ApplicationRecord
  2. mount_uploader :license, LicenseV2Uploader, mount_on: :license_v2
  3. 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:

  1. class User < ApplicationRecord
  2. include LicenseUploader::Attachment(:license)
  3. end

And encrypt in a controller (or background job, etc) with:

  1. license = params.require(:user).fetch(:license)
  2. lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
  3. user.license = lockbox.encrypt_io(license)

To serve encrypted files, use a controller action.

  1. def license
  2. user = User.find(params[:id])
  3. lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
  4. send_data lockbox.decrypt(user.license.read), type: user.license.mime_type
  5. end

Use filename to specify a filename or disposition: "inline" to show inline.

Non-Models

Generate a key

  1. key = Lockbox.generate_key

Create a lockbox

  1. lockbox = Lockbox.new(key: key)

Encrypt files before passing them to Shrine

  1. LicenseUploader.upload(lockbox.encrypt_io(file), :store)

And decrypt them after reading

  1. lockbox.decrypt(uploaded_file.read)

Local Files

Generate a key

  1. key = Lockbox.generate_key

Create a lockbox

  1. lockbox = Lockbox.new(key: key)

Encrypt

  1. ciphertext = lockbox.encrypt(File.binread("file.txt"))

Decrypt

  1. lockbox.decrypt(ciphertext)

Strings

Generate a key

  1. key = Lockbox.generate_key

Create a lockbox

  1. lockbox = Lockbox.new(key: key, encode: true)

Encrypt

  1. ciphertext = lockbox.encrypt("hello")

Decrypt

  1. 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:

  1. Lockbox.default_options[:previous_versions] = [{master_key: previous_key}]

To rotate existing Active Record & Mongoid records, use:

  1. Lockbox.rotate(User, attributes: [:email])

To rotate existing Action Text records, use:

  1. Lockbox.rotate(ActionText::RichText, attributes: [:body])

To rotate existing Active Storage files, use:

  1. User.with_attached_license.find_each do |user|
  2. user.license.rotate_encryption!
  3. end

To rotate existing CarrierWave files, use:

  1. User.find_each do |user|
  2. user.license.rotate_encryption!
  3. # or for multiple files
  4. user.licenses.map(&:rotate_encryption!)
  5. 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.

  1. class User < ApplicationRecord
  2. has_encrypted :email, previous_versions: [{master_key: previous_key}]
  3. end

Local Files & Strings

To rotate local files and strings, use:

  1. 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.

  1. rails generate lockbox:audits
  2. rails db:migrate

Then create an audit wherever a user can view data:

  1. class UsersController < ApplicationController
  2. def show
  3. @user = User.find(params[:id])
  4. LockboxAudit.create!(
  5. subject: @user,
  6. viewer: current_user,
  7. data: ["name", "email"],
  8. context: "#{controller_name}##{action_name}",
  9. ip: request.remote_ip
  10. )
  11. end
  12. end

Query audits with:

  1. 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:

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:

  1. brew install libsodium

And for Ubuntu, use:

  1. sudo apt-get install libsodium23

Then add to your Gemfile:

  1. gem "rbnacl"

And add to your model:

  1. class User < ApplicationRecord
  2. has_encrypted :email, algorithm: "xsalsa20"
  3. end

Make it the default with:

  1. 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:

  1. Lockbox.generate_key_pair

Store the keys with your other secrets. Then use:

  1. class User < ApplicationRecord
  2. has_encrypted :email, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
  3. 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.

  1. Master key
  2. Per field/uploader
  3. 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:

  1. Lockbox.attribute_key(table: "users", attribute: "email_ciphertext")

To rename a table with encrypted columns/uploaders, use:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key_table: "original_table"
  3. end

To rename an encrypted column itself, use:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key_attribute: "original_column"
  3. end

Per Field/Uploader

To set a key for an individual field/uploader, use a string:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"]
  3. end

Or a proc:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key: -> { code }
  3. end

Per Record

To use a different key for each record, use a symbol:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key: :some_method
  3. end

Or a proc:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key: -> { some_method }
  3. end

Key Management

You can use a key management service to manage your keys with KMS Encrypted.

For Active Record and Mongoid, use:

  1. class User < ApplicationRecord
  2. has_encrypted :email, key: :kms_key
  3. end

For Action Text, use:

  1. ActiveSupport.on_load(:action_text_rich_text) do
  2. ActionText::RichText.has_kms_key
  3. end
  4. Lockbox.encrypts_action_text_body(key: :kms_key)

For Active Storage, use:

  1. class User < ApplicationRecord
  2. encrypts_attached :license, key: :kms_key
  3. end

For CarrierWave, use:

  1. class LicenseUploader < CarrierWave::Uploader::Base
  2. encrypt key: -> { model.kms_key }
  3. 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.

  1. lockbox = Lockbox.new(key: key)
  2. lockbox.encrypt("fail").bytesize # 32
  3. lockbox.encrypt("clear").bytesize # 33
  4. lockbox.encrypt("consider").bytesize # 36

Add padding to conceal the exact length of messages.

  1. lockbox = Lockbox.new(key: key, padding: true)
  2. lockbox.encrypt("fail").bytesize # 44
  3. lockbox.encrypt("clear").bytesize # 44
  4. lockbox.encrypt("consider").bytesize # 44

The block size for padding is 16 bytes by default. Lockbox uses ISO/IEC 7816-4#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.

  1. box.encrypt("length15status!").bytesize # 44
  2. box.encrypt("length16status!!").bytesize # 60

Change the block size with:

  1. 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.

  1. lockbox = Lockbox.new(key: key)
  2. ciphertext = lockbox.encrypt(message, associated_data: "somecontext")

Without the same context, decryption will fail.

  1. lockbox.decrypt(ciphertext, associated_data: "somecontext") # success
  2. lockbox.decrypt(ciphertext, associated_data: "othercontext") # fails

You can also use it with database fields and files.

  1. class User < ApplicationRecord
  2. has_encrypted :email, associated_data: -> { code }
  3. end

Binary Columns

You can use binary columns for the ciphertext instead of text columns.

  1. class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.0]
  2. def change
  3. add_column :users, :email_ciphertext, :binary
  4. end
  5. end

Disable Base64 encoding to save space.

  1. class User < ApplicationRecord
  2. has_encrypted :email, encode: false
  3. end

or set it globally:

  1. 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:

  1. class User < ApplicationRecord
  2. attr_encrypted :name, key: key
  3. attr_encrypted :email, key: key
  4. end

Create a migration with:

  1. class MigrateToLockbox < ActiveRecord::Migration[8.0]
  2. def change
  3. add_column :users, :name_ciphertext, :text
  4. add_column :users, :email_ciphertext, :text
  5. end
  6. end

And add has_encrypted to your model with the migrating option:

  1. class User < ApplicationRecord
  2. has_encrypted :name, :email, migrating: true
  3. end

Then run:

  1. Lockbox.migrate(User)

Once all records are migrated, remove the migrating option and the previous model code (the attr_encrypted methods in this example).

  1. class User < ApplicationRecord
  2. has_encrypted :name, :email
  3. end

Then remove the previous gem from your Gemfile and drop its columns.

  1. class RemovePreviousEncryptedColumns < ActiveRecord::Migration[8.0]
  2. def change
  3. remove_column :users, :encrypted_name, :text
  4. remove_column :users, :encrypted_name_iv, :text
  5. remove_column :users, :encrypted_email, :text
  6. remove_column :users, :encrypted_email_iv, :text
  7. end
  8. end

History

View the changelog

Contributing

Everyone is encouraged to help improve this project. Here are a few ways you can help:

To get started with development, install Libsodium and run:

  1. git clone https://github.com/ankane/lockbox.git
  2. cd lockbox
  3. bundle install
  4. bundle exec rake test

For security issues, send an email to the address on this page.