Giter VIP home page Giter VIP logo

lockbox's Introduction

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.

Build Status

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> for Rails 6+)

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

Files

Other

Active Record

Create a migration with:

class AddEmailCiphertextToUsers < ActiveRecord::Migration[7.1]
  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: "[email protected]")

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[7.1]
  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: "[email protected]")

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[7.1]
  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:

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.

  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:

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[7.1]
  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[7.1]
  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[7.1]
  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

Upgrading

1.0.0

encrypts is now deprecated in favor of has_encrypted to avoid conflicting with Active Record encryption.

class User < ApplicationRecord
  has_encrypted :email
end

0.6.0

0.6.0 adds encrypted: true to Active Storage metadata for new files. This field is informational, but if you prefer to add it to existing files, use:

User.with_attached_license.find_each do |user|
  next unless user.license.attached?

  metadata = user.license.metadata
  unless metadata["encrypted"]
    user.license.blob.update!(metadata: metadata.merge("encrypted" => true))
  end
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:

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.

lockbox's People

Contributors

ali-l avatar ankane avatar atul9 avatar doits avatar escoffon avatar geetfun avatar kevin-klein avatar klappradla avatar mattdhill avatar nsnguyen avatar oleh-demyanyuk avatar rickcsong avatar robinvdvleuten avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

lockbox's Issues

How to migrate from Strongbox

When using strongbox to encrypt an attribute, how can I migrate to lockbox? I checked out the migrator but I do not think that would work because it seems to be reading the unencrypted attribute value using send(:attribute_name) but to decrypt an attribute's value strongbox requires to manually call model.attribute.decrypt(password).

Any ideas?

Documentation on how to use with Shrinerb

Thanks for the awesome gem, I have been looking for this! I'm currently working on an app that allows users to upload files the files are sensitive so I need to encrypt them. I'm using Shrinerb for uploading files but I can't seem to get my head of on how to encrypt the files in both cache and store. Tried to follow the similar guidance of active storage and carrierwave but I think it needs more explanation.

OpenSSL::KDF::KDFError: EVP_PKEY_CTX_set_hkdf_key: malloc failure

getting issue on mac os
OpenSSL::KDF::KDFError: EVP_PKEY_CTX_set_hkdf_key: malloc failure
/Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/key_generator.rb:25:in hkdf' /Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/key_generator.rb:25:in hkdf'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/key_generator.rb:14:in attribute_key' /Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox.rb:166:in attribute_key'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/utils.rb:14:in build_box' /Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/model.rb:308:in block (3 levels) in encrypts'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/lockbox-0.2.4/lib/lockbox/model.rb:237:in block (3 levels) in encrypts' /Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:51:in public_send'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:51:in _assign_attribute' /Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:44:in block in _assign_attributes'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:43:in each' /Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:43:in _assign_attributes'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.1/lib/active_record/attribute_assignment.rb:22:in _assign_attributes' /Users/admin/.rvm/gems/ruby-2.6.3/gems/activemodel-6.0.1/lib/active_model/attribute_assignment.rb:35:in assign_attributes'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.1/lib/active_record/core.rb:326:in initialize' /Users/admin/.rvm/gems/ruby-2.6.3/gems/devise-4.7.1/lib/devise/models/database_authenticatable.rb:41:in initialize'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.1/lib/active_record/inheritance.rb:70:in new' /Users/admin/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.1/lib/active_record/inheritance.rb:70:in new'
/Users/admin/.rvm/gems/ruby-2.6.3/gems/activerecord-6.0.1/lib/active_record/persistence.rb:37:in `create'

Ciphertext field type should be 'text', why?

Hi Andrew, thanks for a gem!

I was wondering, why the type of a ciphertext column should be a "text"? What's the reasoning behind that? Is that because of a default size of a blob columns (65535 bytes) or some rails-specific shenanigans that are not possible with VARCHAR?

Cause in my opinion it could be a good option to use VARCHAR in some cases (when you don't want separate reads of data performed by DB: one from a page when all the data of a row is situated and the other one from "blob" storage of your DB)

Migrating Existing Files with Active Storage

Overall, I'm happy with the approach and haven't seen issues while testing, but it'd be great if others could confirm it works for them before taking off the experimental label. Please try it out and share your experience below (even if it's just a few words).

Lockbox with Devise confirmable module weirdness

When using Devise with the confirmable module when a user edits their email address the new email value is stored in the unconfirmed_email attribute and the original email stays in the email attribute. Once the change has been confirmed the unconfirmed_email is copied over to email. This happens in a before_update callback within the confirmable module in the postpone_email_change_until_confirmation_and_regenerate_confirmation_token method which looks something like this:

self.unconfirmed_email = self.email
self.email = self.email_in_database
self.confirmation_token = nil
generate_confirmation_token

When also using Lockbox with Blind Index in this scenario by the time the above code executes the email_ciphertext and email_bidx already have changed values. This results in the user being able to sign in with the new email address before it has been confirmed.

I added the following to my User model which seems to retain the original Devise functionality:

  def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
    super
    restore_email_bidx! if email_bidx_changed?
    restore_email_ciphertext! if email_ciphertext_changed?
  end

Would this be something that could be handled by the Lockbox / Blind Index gems?

Clear up ActiveRecord documentation

Thank you for creating this library.

For ActiveRecord, it would be great to add a warning message when the encrypted attribute also exists as a db column on the model.

For instance, if you have a pre-existing :email column, and add the :email_ciphertext column, user.update(email: '[email protected]') will save BOTH the plaintext value to the :email column, and the encrypted value to :email_ciphertext.

Alternatively, adding a note to the ActiveRecord instructions, reminding people to delete the unencrypted column if it exists would be nice and could prevent people accidentally saving sensitive data to their model.

Lockbox and Devise two_factor_authenticatable

I came across Lockbox the other day while looking at some guides around securing sensitive data in a Rails app, and found it to be a good candidate to migrate my existing two-factor setup, using devise-two-factor. The devise-two-factor generator adds a few columns to the specified model:

  • encrypted_otp_secret
  • encrypted_otp_secret_iv
  • encrypted_otp_secret_salt
  • consumed_timestep
  • otp_required_for_login

Furthermore, devise-two-factor encrypts its secrets before storing them in the database - I have an encryption key stored as an environment variable. The encryption key is set in the specified model that uses Devise:

 devise :two_factor_authenticatable,
         :otp_secret_encryption_key => ENV['ENCRYPTION_KEY']

I know Lockbox makes it easy to migrate from another library, and I'll admit that I haven't given this a go yet, but I was curious if you had any suggestions on how to support this.

"plucking" an encrypted attribute fails

When plucking an encrypted attribute from a model instance, the attribute is declared as missing. For example, this statement fails:

User.all.pluck(:email)

For anyone experiencing this issue, as an alternative, mapping works as expected:

User.all.map(&:email)

Apologies ankane, I'd submit a PR but wouldn't know where to start for this one.

uninitialized constant OpenSSL::KDF

Some openssl distributions don't export the OpenSSL::KDF symbol, and therefore the check in KeyGenerator#hkdf crashes. Here is output from one of my test cases:

  1) Opo::Document::Base#initialize should accept owner, title, note, and contents
     Failure/Error: rv = super(attrs)
     
     NameError:
       uninitialized constant OpenSSL::KDF
     # /usr/local/rvm/gems/ruby-2.4.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/active_support.rb:79:in `block in load_missing_constant'
     # /usr/local/rvm/gems/ruby-2.4.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/active_support.rb:8:in `without_bootsnap_cache'
     # /usr/local/rvm/gems/ruby-2.4.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/active_support.rb:79:in `rescue in load_missing_constant'
     # /usr/local/rvm/gems/ruby-2.4.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/active_support.rb:59:in `load_missing_constant'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox/key_generator.rb:37:in `hkdf'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox/key_generator.rb:14:in `attribute_key'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox.rb:134:in `attribute_key'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox/utils.rb:14:in `build_box'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox/active_storage_extensions.rb:18:in `encrypt_attachable'
     # /Users/escoffon/src/gems/lockbox/lib/lockbox/active_storage_extensions.rb:55:in `attach'
     # /usr/local/rvm/gems/ruby-2.4.0/gems/activestorage-5.2.3/lib/active_storage/attached/macros.rb:37:in `contents='
     # ./app/models/opo/document/base.rb:133:in `initialize'
     # ./app/models/opo/document/text.rb:16:in `initialize'
     # ./spec/models/opo/document/base_spec.rb:37:in `block (3 levels) in <main>'
     # ------------------
     # --- Caused by: ---
     # NameError:
     #   uninitialized constant OpenSSL::KDF
     #   /usr/local/rvm/gems/ruby-2.4.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/active_support.rb:60:in `block in load_missing_constant'

PR #8 has a fix where I first check if OpenSSL::KDF exists, and then if it exports hkdf

UTF-8 encoding issue

It looks like UTF-8 characters does not decrypt correctly.

class Message < ApplicationRecord
  encrypts :content
...
end
2.4.0 :005 > @message.content
=> "\xC5\x81ukasz \xC5\x9Aliwa"
2.4.0 :007 > @message.content.force_encoding('UTF-8')
=> "Łukasz Śliwa"
2.4.0 :010 > @message.content_ciphertext.encoding
=> #<Encoding:UTF-8>
2.4.0 :011 > @message.content.encoding
=> #<Encoding:ASCII-8BIT>

I think it should be

 #<Encoding:UTF-8>

It throws in the View:

incompatible character encodings: UTF-8 and ASCII-8BIT

Update1

Added type: :string attribute and it throws an exception if the message contains some extra characters like Ł or Ś

Encoding::UndefinedConversionError: "\xC5" from ASCII-8BIT to UTF-8
2019-07-31T16:50:15.210Z 18948 TID-gsiqacu88 WARN: /home/lukasz/.../lib/lockbox/model.rb:224:in `encode'

Ideas

0.7.0

  • Drop support for Active Record < 5.2 and Ruby < 2.6
  • Raise an error for unsupported versions of Active Record and CarrierWave

1.0.0 (breaking)

  • Add binary option to replace encode and encode by default for Lockbox.new
  • [maybe] Decode to UTF-8 unless binary: true - utf8 branch
  • Don't encode in Base64 for binary database fields (if simple to implement)
  • [maybe] Create new blobs when blobs are attached without encrypted flag

Considering

  • Lockbox::Redis and Lockbox::Dalli for cache stores (probably better as separate gem) - cache_stores branch - or Lockbox::Cache::Store for Active Support cache store
  • Prefer encrypt_attribute and decrypt_attribute over generate_attribute_ciphertext and decrypt_attribute_ciphertext
  • Don't return virtual attributes in attribute methods (attributes, attribute_names, has_attribute?) unless the ciphertext attribute is present
  • Use Fiddle for Libsodium - libsodium branch
  • Warn (and eventually throw error) if the master key is passed to Lockbox.new
  • Require allow_empty option to encrypt empty string without padding
  • Encrypt empty strings in database fields - model_empty_string branch
  • Add support for encrypted Active Storage service (can wrap any other service) - more useful in 6.1+ since multiple services are supported (blocked since encryption needs to happen before checksum is computed)
  • Store the encryption version to make it easy to see which data has been rotated and avoid trying multiple keys. Could be done in an optional new field (email_ciphertext_version, license_version, blob metadata) or directly in the ciphertext (needs to work for files/binary data)
  • Prefer encrypts over encrypt for CarrierWave
  • Default padding for encoded strings to reduce data leakage (cons: less standard, slightly more space)
  • Prefer ActiveSupport.on_load(:action_text_rich_text) { ActionText::RichText.encrypts :body } over Lockbox.encrypts_action_text_body (more code but less magic)
  • Add associated_data option to models
  • Add pretty_print method (similar to inspect)

On hold

  • Support for streaming encryption (probably not needed) - streaming branch
  • Better support for KMS (store key in data/metadata instead of DB) - kms_encrypt branch
  • Shrine support - shrine branch - WIP

Migrate an existing database with validate: false enabled

Thanks for the gem ankane, another solid contribution to the OS community.

For my use case, how I validate this particular model has changed over time, but for the purposes of my application, I am unable to update historical records to meet the latest validation criteria.

Is there are a handy way to run Lockbox.migrate(ModelName) with validate: false enabled on the save action?

Typo

Note: KMS Encrypted’s key rotation does not know to rotate encrypted files => Note: KMS Encrypted’s key rotation does not know *how* to rotate encrypted files.

Additionally consider Note: the key rotation of KMS Encrypted instead of using a possessive s.

Large tables

HI, does this approach work well with large tables or does it slow things down somehow? Thanks

Viewing on heroku deployment requires key to be binary

2020-05-26T23:28:32.474964+00:00 app[web.1]: I, [2020-05-26T23:28:32.474884 #4] INFO -- : [4cff2ecd-3543-4cc3-aad3-4c88606653b1] Completed 500 Internal Server Error in 3ms (ActiveRecord: 0.0ms) 2020-05-26T23:28:32.476537+00:00 app[web.1]: F, [2020-05-26T23:28:32.476463 #4] FATAL -- : [4cff2ecd-3543-4cc3-aad3-4c88606653b1] 2020-05-26T23:28:32.476776+00:00 app[web.1]: F, [2020-05-26T23:28:32.476692 #4] FATAL -- : [4cff2ecd-3543-4cc3-aad3-4c88606653b1] Lockbox::Error (Key must use binary encoding): 2020-05-26T23:28:32.476912+00:00 app[web.1]: F, [2020-05-26T23:28:32.476806 #4] FATAL -- : [4cff2ecd-3543-4cc3-aad3-4c88606653b1] 2020-05-26T23:28:32.477081+00:00 app[web.1]: F, [2020-05-26T23:28:32.476982 #4] FATAL -- : [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/utils.rb:39:in decode_key'
2020-05-26T23:28:32.477081+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/key_generator.rb:14:in attribute_key' 2020-05-26T23:28:32.477083+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox.rb:84:in attribute_key'
2020-05-26T23:28:32.477083+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/utils.rb:15:in build_box' 2020-05-26T23:28:32.477084+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/model.rb:327:in block (3 levels) in encrypts'
2020-05-26T23:28:32.477084+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/model.rb:246:in block (3 levels) in encrypts' 2020-05-26T23:28:32.477084+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/lockbox-0.4.2/lib/lockbox/model.rb:224:in block (3 levels) in encrypts'
2020-05-26T23:28:32.477085+00:00 app[web.1]: [4cff2ecd-3543-4cc3-aad3-4c88606653b1] vendor/bundle/ruby/2.5.0/gems/blind_index-2.0.1/lib/blind_index/model.rb:77:in block (4 levels) in blind_index'

This is the error log i get when i try to run the controller method to create a new user on heroku. Everything works fine on my local machine but when viewing it on heroku it says that the key needs to use binary encoding. Do you have a guide on how to get it working on heroku? i am also using Blind_index, after you showed me how to use the original field names from earlier on your website.

"TypeError: can't convert message to string" error when migrating actiontext

Hello,

I am following the documentation, seems simple :), but when I do "Lockbox.migrate(ActionText::RichText)" in the console,
I get this error message: "TypeError: can't convert message to string".

I've search on the internet and in the passed lockbox issue but I cannot figured out where is the problem.

Is someone run onto this problem ?

Thanks in advance for answers.

trouble with environment vars using lockbox

Hi Andrew,

I'm having trouble loading ENV variables for my keys when using Lockbox.

Lockbox is working for encrypting email in the User model in my development environment if I hard code the key, e.g.
encrypts :email, key: "0000000000000000000000000000000000000000000000000000000000000000"

However when I try to use my Heroku config environment variables to store a secret key no data is loaded into the key in the User model... but data is loaded into the keys for the other models which I am using attr_encrypted for.
For example; I set my ENV variable with:
heroku config:set LB_KEY="0000000000000000000000000000000000000000000000000000000000000000"
then heroku config gives:
LB_KEY: 0000000000000000000000000000000000000000000000000000000000000000

However in the User model when I use:
lb_key = ENV['LB_KEY'].to_s
puts (lb_key)
puts (lb_key.length)

I get nothing for lb_key, and 0 for lb_key.length

I have tried lots of permutations of trying to load the environment variable, e.g. [ENV[LB_KEY]].pack("H*") but it seems that no data is ever loaded from the heroku config variable. This is strage to me as this is how I load my keys for attr_encrypted, e.g. in my Name model I have
if Rails.env.development? secret_name_key="4567812345678123456781234567812e" elsif Rails.env.production? secret_name_key = ENV['DB_Name_Encryption_Key'] end
and both the hard coded key for development and the environment variable key for production load fine.

Be great if you could give me a hand with this.
Cheers,
Chris

update_attribute is not updating indexes

Example:

class User < ActiveRecord::Base
  encrypts :email
  blind_index :email
end

Calling User.first.update_attribute(:email, '[email protected]') updates the encrypted email column but doesn't update the encrypted index column.
Am I doing something wrong?

Serialized attributes break when saved/read multiple times

In particular, this appears to affect both the json and hash type.

The following tests appear to fail:

  def test_type_json_save_twice
    data2 = {a: 1, b: "hi"}
    user = User.create!(data2: data2)
    user.reload
    user.save!

    user.data2
    user.save!

    new_data2 = {"a" => 1, "b" => "hi"}
    assert_equal new_data2, user.data2
    # raises JSON error
  end

  def test_type_hash_save_twice
    info2 = {a: 1, b: "hi"}
    user = User.create!(info2: info2)
    user.reload
    user.save!

    user.info2
    user.save!
    assert_equal info2, user.info2
    # returns {} instead of {a: 1, b: "hi"}
  end

From investigation, it appears that this is due to the type being coerced to a string (but the original value before type cast is a hash, not a string).

For the :json type, a quick fix was to remove the serialize and rely on ActiveRecord::Type::Json to deserialize.

i.e.

              attribute_type =
                case options[:type]
                when :hash
                  :string
                when :json
                  ActiveRecord::Type::Json.new
                when :integer
                  ActiveRecord::Type::Integer.new(limit: 8)
                else
                  options[:type]
                end

              attribute name, attribute_type

              serialize name, Hash if options[:type] == :hash

Unfortunately, there does not appear to be a straightforward fix if you require serialize.

Thanks so much for the help in investigating!

model.attribute? is nil

Normally in Active Record you can call user.email? and receive true if the email attribute exists. This returns false if user.email has not already been loaded.
ex.

u = User.first

u.email?
=> false

u.email
=> [email protected]

u.email?
=> true

Methods such as nil?, present?, blank? work as expected. And perhaps this is expected behavior as well, but it behaves slightly different when the email column actually exists.

The devise controller is not able to use the encrypted column.

error: ActiveRecord::StatementInvalid in Devise::RegistrationsController#create
description: PG::UndefinedColumn: ERROR: column users.email does not exist LINE 1: SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIM... ^ : SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2

I used lockbox to encrypt the email field in my users table and added encrypts :email, type: :string in my model user.rb but it is unable to create a new record as the email field does not exist as i followed your instructions and deleted the email column, from the db after the migration. My understanding was that the line in the model file allowed you to use the original column names for forms but would encrypt them and put them in the encrypted columns when inputting them into the table. Is my understanding wrong or am i doing something incorrect.

Integrating with Sorcery

Hello,

I've run into an issue where login is failing while using the Sorcery GEM (Authentication) and lockbox (which is really nice, thank you).

I haven't dug very deep but was wondering if anyone has any insight into the cause?

Thank you,
James

Error:

PG::UndefinedColumn: ERROR: column users.email does not exist LINE 1: SELECT "users".* FROM "users" WHERE "users"."email" = 'test@... ^ 

I have use blind_index and can query User.where(email: '[email protected]')

The stack trace is:

activerecord (6.0.3) lib/active_record/connection_adapters/postgresql_adapter.rb:744:in `prepare'
activerecord (6.0.3) lib/active_record/connection_adapters/postgresql_adapter.rb:744:in `block in prepare_statement'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:26:in `block (2 levels) in synchronize'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
activerecord (6.0.3) lib/active_record/connection_adapters/postgresql_adapter.rb:739:in `prepare_statement'
activerecord (6.0.3) lib/active_record/connection_adapters/postgresql_adapter.rb:684:in `exec_cache'
activerecord (6.0.3) lib/active_record/connection_adapters/postgresql_adapter.rb:658:in `execute_and_clear'
activerecord (6.0.3) lib/active_record/connection_adapters/postgresql/database_statements.rb:98:in `exec_query'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/database_statements.rb:493:in `select_prepared'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/database_statements.rb:68:in `select_all'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/query_cache.rb:105:in `block in select_all'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/query_cache.rb:122:in `block in cache_sql'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:26:in `block (2 levels) in synchronize'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
activesupport (6.0.3) lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/query_cache.rb:113:in `cache_sql'
activerecord (6.0.3) lib/active_record/connection_adapters/abstract/query_cache.rb:105:in `select_all'
activerecord (6.0.3) lib/active_record/querying.rb:46:in `find_by_sql'
activerecord (6.0.3) lib/active_record/relation.rb:821:in `block in exec_queries'
activerecord (6.0.3) lib/active_record/relation.rb:839:in `skip_query_cache_if_necessary'
activerecord (6.0.3) lib/active_record/relation.rb:808:in `exec_queries'
activerecord (6.0.3) lib/active_record/relation.rb:626:in `load'
activerecord (6.0.3) lib/active_record/relation.rb:250:in `records'
activerecord (6.0.3) lib/active_record/relation.rb:245:in `to_ary'
activerecord (6.0.3) lib/active_record/relation/finder_methods.rb:527:in `find_nth_with_limit'
activerecord (6.0.3) lib/active_record/relation/finder_methods.rb:512:in `find_nth'
activerecord (6.0.3) lib/active_record/relation/finder_methods.rb:120:in `first'
sorcery (0.15.0) lib/sorcery/adapters/active_record_adapter.rb:72:in `find_by_credentials'
sorcery (0.15.0) lib/sorcery/model.rb:97:in `authenticate'
sorcery (0.15.0) lib/sorcery/controller.rb:40:in `login'
app/controllers/sessions_controller.rb:10:in `create'
actionpack (6.0.3) lib/action_controller/metal/basic_implicit_render.rb:6:in `send_action'
actionpack (6.0.3) lib/abstract_controller/base.rb:195:in `process_action'
actionpack (6.0.3) lib/action_controller/metal/rendering.rb:30:in `process_action'
actionpack (6.0.3) lib/abstract_controller/callbacks.rb:42:in `block in process_action'
activesupport (6.0.3) lib/active_support/callbacks.rb:135:in `run_callbacks'
actionpack (6.0.3) lib/abstract_controller/callbacks.rb:41:in `process_action'
actionpack (6.0.3) lib/action_controller/metal/rescue.rb:22:in `process_action'
actionpack (6.0.3) lib/action_controller/metal/instrumentation.rb:33:in `block in process_action'
activesupport (6.0.3) lib/active_support/notifications.rb:180:in `block in instrument'
activesupport (6.0.3) lib/active_support/notifications/instrumenter.rb:24:in `instrument'
activesupport (6.0.3) lib/active_support/notifications.rb:180:in `instrument'
actionpack (6.0.3) lib/action_controller/metal/instrumentation.rb:32:in `process_action'
actionpack (6.0.3) lib/action_controller/metal/params_wrapper.rb:245:in `process_action'
activerecord (6.0.3) lib/active_record/railties/controller_runtime.rb:27:in `process_action'
actionpack (6.0.3) lib/abstract_controller/base.rb:136:in `process'
actionview (6.0.3) lib/action_view/rendering.rb:39:in `process'
actionpack (6.0.3) lib/action_controller/metal.rb:190:in `dispatch'
actionpack (6.0.3) lib/action_controller/metal.rb:254:in `dispatch'
actionpack (6.0.3) lib/action_dispatch/routing/route_set.rb:50:in `dispatch'
actionpack (6.0.3) lib/action_dispatch/routing/route_set.rb:33:in `serve'
actionpack (6.0.3) lib/action_dispatch/journey/router.rb:49:in `block in serve'
actionpack (6.0.3) lib/action_dispatch/journey/router.rb:32:in `each'
actionpack (6.0.3) lib/action_dispatch/journey/router.rb:32:in `serve'
actionpack (6.0.3) lib/action_dispatch/routing/route_set.rb:834:in `call'
rack (2.2.2) lib/rack/tempfile_reaper.rb:15:in `call'
rack (2.2.2) lib/rack/etag.rb:27:in `call'
rack (2.2.2) lib/rack/conditional_get.rb:40:in `call'
rack (2.2.2) lib/rack/head.rb:12:in `call'
actionpack (6.0.3) lib/action_dispatch/http/content_security_policy.rb:18:in `call'
rack (2.2.2) lib/rack/session/abstract/id.rb:266:in `context'
rack (2.2.2) lib/rack/session/abstract/id.rb:260:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/cookies.rb:648:in `call'
activerecord (6.0.3) lib/active_record/migration.rb:567:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/callbacks.rb:27:in `block in call'
activesupport (6.0.3) lib/active_support/callbacks.rb:101:in `run_callbacks'
actionpack (6.0.3) lib/action_dispatch/middleware/callbacks.rb:26:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/executor.rb:14:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/actionable_exceptions.rb:17:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/debug_exceptions.rb:32:in `call'
web-console (4.0.1) lib/web_console/middleware.rb:132:in `call_app'
web-console (4.0.1) lib/web_console/middleware.rb:28:in `block in call'
web-console (4.0.1) lib/web_console/middleware.rb:17:in `catch'
web-console (4.0.1) lib/web_console/middleware.rb:17:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/show_exceptions.rb:33:in `call'
railties (6.0.3) lib/rails/rack/logger.rb:37:in `call_app'
railties (6.0.3) lib/rails/rack/logger.rb:26:in `block in call'
activesupport (6.0.3) lib/active_support/tagged_logging.rb:80:in `block in tagged'
activesupport (6.0.3) lib/active_support/tagged_logging.rb:28:in `tagged'
activesupport (6.0.3) lib/active_support/tagged_logging.rb:80:in `tagged'
railties (6.0.3) lib/rails/rack/logger.rb:26:in `call'
sprockets-rails (3.2.1) lib/sprockets/rails/quiet_assets.rb:13:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/remote_ip.rb:81:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/request_id.rb:27:in `call'
rack (2.2.2) lib/rack/method_override.rb:24:in `call'
rack (2.2.2) lib/rack/runtime.rb:22:in `call'
activesupport (6.0.3) lib/active_support/cache/strategy/local_cache_middleware.rb:29:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/executor.rb:14:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/static.rb:126:in `call'
rack (2.2.2) lib/rack/sendfile.rb:110:in `call'
actionpack (6.0.3) lib/action_dispatch/middleware/host_authorization.rb:82:in `call'
webpacker (4.2.2) lib/webpacker/dev_server_proxy.rb:23:in `perform_request'
rack-proxy (0.6.5) lib/rack/proxy.rb:57:in `call'
railties (6.0.3) lib/rails/engine.rb:527:in `call'
puma (4.3.5) lib/puma/configuration.rb:228:in `call'
puma (4.3.5) lib/puma/server.rb:713:in `handle_request'
puma (4.3.5) lib/puma/server.rb:472:in `process_client'
puma (4.3.5) lib/puma/server.rb:328:in `block in run'
puma (4.3.5) lib/puma/thread_pool.rb:134:in `block in spawn_thread'

Support for JRuby?

I have forked this and will try it on JRuby Rails apps and post an update

decryption failed?

Thanks so much for making this useful gem! I'm confused about something, though. I want to show the encrypted files in my view. Assuming that I have a field named doc_front_ciphertext in my migration, I should be able to decrypt with:

box = Lockbox.new(key: ENV['LOCKBOX_MASTER_KEY'])
box.decrypt(user.license.doc_front_ciphertext)

However, this results in a Decryption failed error. What am I doing wrong?

Lockbox::DecryptionError even when the old key is provided in previous_versions

I install lockbox-0.3.1
I set up a model with encrypts :patron_email
I run the migration add_column :r_and_r_items, :patron_name_ciphertext, :text
I set my master key in dev:

lockbox_master_key: '0000000000000000000000000000000000000000000000000000000000000000'

I persist an item with this key.
It's persisted correctly -- the value in the database is encrypted, and the value as shown is correct.

Let's suppose my key is compromised.
I now change the lockbox_master_key used to

lockbox_master_key: '000000000000000000000000000000000000000000000000000000000000aaaa'

As expected, I get a Lockbox::DecryptionError: Decryption failed if I try to decrypt the encoded patron_email with the new key.

I now change the model to:

encrypts :patron_email, previous_versions: [{key: "0000000000000000000000000000000000000000000000000000000000000000"}]

Expected behavior:

Lockbox attempts to decypher the encrypted patron_email, fails, then tries with the key listed in previous_versions, succeeds, and returns that.

Actual behavior:

Lockbox::DecryptionError: Decryption failed is thrown.

Question: Are encrypted values portable?

Something I just tested out:

foo_record = Foo.find(1)
bar_record = Bar.find(1)

foo_record.encrypted_value
> "foo"

bar_record.encrypted_value
> "bar"

# let's just move raw ciphertext
foo_record.update_column(:encrypted_value_ciphertext, bar_record.encrypted_value_ciphertext)

foo_record.encrypted_value
> Lockbox::DecryptionError: Decryption failed

This is a bit concerning because it looks like encryption cares about model and attributes names. Seems like it has something to do with Lockbox::Utils.build_box. I don't really understand what it does.

Now, what happens if I migrate table/attribute to a different name and change model class name? Will this basically make all my data inaccessible? Looks like it.

I triggered this error by copy-pasting some fixtures that use Foo.generate_encrypted_value_ciphertext("foo").inspect. Honestly, I expected to have a generic API like Lockbox.encrypt_value("foo", type: Hash). Something that is not explicitly tied to a model.

It's not currently causing any issues for me, but it's a gigantic gotcha and makes simple migrations incredibly complicated.

Thanks

undefined method `will_save_change_to_email?' for #<User

Bonjour,

I am trying to encrypt email for user; I am using devise with this set:
:invitable, :database_authenticatable, :async, :registerable, :confirmable, :recoverable, :rememberable, :validatable

The error: undefined method `will_save_change_to_email?' for #<User

The last line of trace:

activemodel (6.0.3.2) lib/active_model/attribute_methods.rb:432:in method_missing' devise (4.7.2) lib/devise/models/confirmable.rb:282:in postpone_email_change?'
activesupport (6.0.3.2) lib/active_support/callbacks.rb:428:in block in make_lambda' activesupport (6.0.3.2) lib/active_support/callbacks.rb:180:in block (2 levels) in halting_and_conditional'

It happen when I try to logout.

Cordialement,

Unable to use #select or #pluck

Since these methods use the exact provided name as column name, we can't use them.
Example:

class User < ActiveRecord::Base
  encrypts :email
  
  def self.all_emails
    pluck(:email)
  end
end

Calling User.all_emails raises ActiveRecord::StatementInvalid (PG::UndefinedColumn: ERROR: column users.email does not exist)

To fix this, we had to recode as follows:

class User < ActiveRecord::Base
  encrypts :email
  
  def self.all_emails
    select(:email_ciphertext).map(&:email)
  end
end

But that is not very elegant. Could we do better than this?

Ciphertext changes when the same value is reassigned

I don't know if this is a bug or the expected behavior. I have a class (User) that encrypts a field (email) in the encrypted field (email_ciphertext).

Every time I assign (through email= or assign_attributes) the same value to email it regenerates a new ciphertext (I guess for security to use a different salt?). The changes also shows that the email_ciphertext changed, but not the email (as it stayed the same).

Is this expected or should ignore the change?

  user = User.create(email: '[email protected]')
  previous_ciphertext = user.email_ciphertext
  user.email = user.email
  user.email_ciphertext != previous_ciphertext
  user.changes

How can I use lockbox gem with ransack gem?

Using Lockbox gem I encrypted my email column in the database (removed email column and added email_ciphertext, email_bidx column) but now I'm facing error when trying to use <%= f.search_field :email_cont %>. Please suggest me how can I handle it.

Error:

undefined method `email_cont' for Ransack::Search<class: User, base: Grouping <combinator: and>>:Ransack::Search

Rotate keys without updating model code

Hi, I'm new to using this library, and so far it's been super easy to use. Great work! I see the documented way to add the previous_versions option to the model, then run Lockbox.rotate. This is fine in development, but I'm wondering how this might be used in a production environment that requires keys to be rotated somewhat frequently. Is there a way to rotate the keys without updating model code? I was thinking it would be convenient to have a rake task to rotate the keys. I should note that I'm really only envisioning using a master key, so my idea of running the task would look something like this:

$ OLD_LOCKBOX_MASTER_KEY="123" LOCKBOX_MASTER_KEY="456" rake db:rotate_keys

Thanks in advance for any help!

decrypt encrypted file

I want to decrypt the encrypted file, but I am getting Lockbox::DecryptionError: Decryption failed
error. how can I do it?

box.decrypt(File.read("credentials.yml"))

Hybrid Strategy

Hello,

Thanks for making this! It is just the thing I was looking for.

I'm trying to use the hybrid functionality and was wondering about the best way to support a nil decryption key. For example if I have an encrypted email field like:

ActiveRecord::Schema.define do
  create_table :users do |t|
    t.text :email_ciphertext
  end
end

class User < ApplicationRecord
  encrypts :email, algorithm: "hybrid", encryption_key: "encryption_key", decryption_key: nil
end

Selecting a record like User.first, will raise an ArgumentError (No private key set) exception.

One workaround I could think of was selecting everything minus the email field like:

User.select(*(User.column_names - ["email_ciphertext"])).first

I was wondering if there is or could be a more ergonomic way? Some ideas I had:

  1. Make decryption lazy so nothing happens until User#email is called. At this point it could raise or not based on configuration.
  2. Make all hybrid ciphertext fields nil if no decryption_key is set.

Thanks!

Encrypting JSON attributes

Really interested by this gem! I had written something for attr_encrypted to pack attributes into a single field via custom encryptor but not a huge fan of that approach.

I am curious if its within the scope of this gem to support encoding / decoding the attribute before it is encrypted? We have a few columns where we store a encrypted json payload, with attr_encrypted this is done via the marshal option:

attr_encrypted :provider_credentials, marshal: true, marshaler: JSON

Can't use Encrypts method on non ActiveRecord::Base class

I have class Test, wich includes Lockbox::Model

class Test
   include Lockbox::Model
end

when i'm calling

Test.new.encrypts(337, type: :integer)

I get error:

undefined method < for #<Test:0x0000564e62b78868> /home/***/.rvm/gems/ruby-2.6.5/gems/lockbox-0.3.1/lib/lockbox/model.rb:24:in encrypts'

I think, i'm geeting error on this line

activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base

Unit test failing when column type is json

I got error on my unit test when trying to save a model with column type is json in my Company model:
Company.create(metadata: { content: "something" })

encrypts :metadata, type: :json

NoMethodError: undefined methodmetadata_ciphertext=' for #Company:0x000055fbe0727ce8
Did you mean? metadata_change
`

And this points me to:
https://github.com/ankane/lockbox/blob/v0.3.3/lib/lockbox/model.rb#L213

When using another model with column type string, it works fine.

Issues Generating the Encryption key

Hi, I'm having issues generating the encryption key. Specifically, when I run the command Lockbox.generate_key, I receive the following error:

"Lockbox.generate_key: command not found."

I executed all the other steps from the documentation without any issues.

I'm using AWS Cloud9, so I'm not sure if this is an issue caused by my development environment. Your assistance with this matter is greatly appreciated. Also, I am new to programming, so I apologize if this is not the correct forum for this question.

Lockbox::Error: Key must use binary encoding

I used this guide to setup lockbox.

Gemfile

gem 'lockbox', github: 'ankane/lockbox'
gem 'blind_index'

Migration

  create_table :users do |t|
    t.text   :email_ciphertext, null: false
    t.string :email_bidx, null: false
  end

  add_index :users, :email_bidx, unique: true

User model looks like

class User < ApplicationRecord
  encrypts :email
  blind_index :email
end

And now trying

[1] pry(main)> Lockbox.master_key.present?
=> true
[2] pry(main)> User.create!(email: '[email protected]')
Lockbox::Error: Key must use binary encoding
from /Users/max-si-m/.rbenv/versions/2.6.4/gemsets/team/bundler/gems/lockbox-4c17b4e70712/lib/lockbox/utils.rb:39:in `decode_key'
[3] pry(main)>

Using rails 6.0.3
Using lockbox 0.4.2

Did I do something wrong?

NoMethodError: undefined method `except' for false:FalseClass

Hey Ankane,

I'm getting an error that's related to ActiveStorage. Here's the relevant backtrace:

NoMethodError: undefined method 'except' for false:FalseClass ruby-2.6.3/gems/lockbox-0.2.0/lib/lockbox/utils.rb:4:in 'build_box' ruby-2.6.3/gems/lockbox-0.2.0/lib/lockbox/active_storage_extensions.rb:18:in 'encrypt_attachable' ruby-2.6.3/gems/lockbox-0.2.0/lib/lockbox/active_storage_extensions.rb:55:in 'attach' ruby-2.6.3/gems/active_storage_base64-0.1.4/lib/active_storage_support/base64_one.rb:5:in 'attach'

It's happening on my User model where I have a bunch of encrypted attributes, and two has_one_attached avatars which I'm not encrypting.

I'm still going through the Lockbox code, however my hunch is that Lockbox is trying to attach to the has_one_attached attribute even though I haven't flagged it as encrypted, and returns false because of it.

I've forked the repo and disabled the loading of the ActiveStorage classes in railties.rb and everything works fine.

Keen to continue using Lockbox! Thanks for your work!

  • Adrian

Lockbox and ActionText

Using Rails, I wanted to encrypt ActionText records and thought I would share how I did it here. @ankane you're welcome to include it in the official documentation or leave it here for posterity's sake.

ActionText Encrypted

  1. Add column
  2. Migrate
  3. Write to encrypted column
  4. Remove unencrypted column

Add column

# db/migrate/...
class AddBodyCiphertextToActionTextRichTexts < ActiveRecord::Migration[6.0]
  def change
    add_column :action_text_rich_texts, :body_ciphertext, :text
  end
end

# initializers/patches.rb
module Patches
  module ActionText
    module EncryptedBody
      extend ActiveSupport::Concern

      included do
        encrypts :body, migrating: true
      end
    end
  end
end

ActiveSupport.on_load(:action_text_rich_text) do
  ActionText::RichText.include(Patches::ActionText::EncryptedBody)
end

ActiveSupport.on_load(:action_text_content) do
  module Patches
    module ActionText
      module IO
        extend ActiveSupport::Concern

        def read
          to_s
        end
      end
    end
  end

  ActionText::Content.include(Patches::ActionText::IO)
end

Migrate

Lockbox.migrate(ActionText::RichText)

Write to encrypted column

# initializers/patches.rb
module Patches
  module ActionText
    module EncryptedBody
      extend ActiveSupport::Concern

      included do
        encrypts :body
        self.ignored_columns = ["body"]
      end
    end
  end
end

ActiveSupport.on_load(:action_text_rich_text) do
  ActionText::RichText.include(Patches::ActionText::EncryptedBody)
end

Remove unencrypted column

class RemoveBodyFromActionTextRichTexts < ActiveRecord::Migration[6.0]
  def change
    remove_column :action_text_rich_texts, :body, :text
  end
end
# initializers/patches.rb
module Patches
  module ActionText
    module EncryptedBody
      extend ActiveSupport::Concern

      included do
        encrypts :body
      end
    end
  end
end

ActiveSupport.on_load(:action_text_rich_text) do
  ActionText::RichText.include(Patches::ActionText::EncryptedBody)
end

Email not saving via Devise form

Hello,

I can create/update a user in the console with user.email = "[email protected]", but when I try saving via form, nothing seems to happen. I found a solution by adding the following to my ApplicationController:

  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    added_attrs = %i[email]
    devise_parameter_sanitizer.permit :sign_up, keys: added_attrs
    devise_parameter_sanitizer.permit :account_update, keys: added_attrs
  end

With having deleted the email column, Devise seems to automatically label is as an unpermitted parameter. I'm leaving this for anyone else having troubles migrating their Users to Lockbox who use Devise.

Issue with `User.generate_email_ciphertext` and non-string values

First of all, thanks for the encryption library that actually nice to use. I'm currently migrating off the attr_encrypted and everything seems to work great. However I'm running into issues setting fixtures.

I have models that use type: :hash and type: :integer.

As per README, using the MyModel.generate_some_hash_ciphertext({some: "hash"}) errors out with "can't convert message to string". Same goes for integer type.

Looking at the code it seems that it expects a string (or something that can be .read).

What am I missing? Thanks.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.