lockbox-0.6.4/0000755000004100000410000000000014042066300013205 5ustar www-datawww-datalockbox-0.6.4/README.md0000644000004100000410000006122314042066300014470 0ustar www-datawww-data# 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](https://ankane.org/modern-encryption-rails), [how to secure emails with Devise](https://ankane.org/securing-user-emails-lockbox), and [how to secure sensitive data in Rails](https://ankane.org/sensitive-data-rails). [![Build Status](https://github.com/ankane/lockbox/workflows/build/badge.svg?branch=master)](https://github.com/ankane/lockbox/actions) ## Installation Add this line to your application’s Gemfile: ```ruby gem 'lockbox' ``` ## Key Generation Generate a key ```ruby Lockbox.generate_key ``` Store the key with your other secrets. This is typically Rails credentials or an environment variable ([dotenv](https://github.com/bkeepers/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) ```sh LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000 ``` or add it to your credentials for each environment (`rails credentials:edit --environment ` for Rails 6+) ```yml lockbox: master_key: "0000000000000000000000000000000000000000000000000000000000000000" ``` or create `config/initializers/lockbox.rb` with something like ```ruby 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](#active-record) - [Action Text](#action-text) - [Mongoid](#mongoid) #### Files - [Active Storage](#active-storage) - [CarrierWave](#carrierwave) - [Shrine](#shrine) - [Local Files](#local-files) #### Other - [Strings](#strings) ## Active Record Create a migration with: ```ruby class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :email_ciphertext, :text end end ``` Add to your model: ```ruby class User < ApplicationRecord encrypts :email end ``` You can use `email` just like any other attribute. ```ruby User.create!(email: "hi@example.org") ``` If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index). #### Multiple Fields You can specify multiple fields in single line. ```ruby class User < ApplicationRecord encrypts :email, :phone, :city end ``` #### Types Fields are strings by default. Specify the type of a field with: ```ruby class User < ApplicationRecord encrypts :born_on, type: :date encrypts :signed_at, type: :datetime encrypts :opens_at, type: :time encrypts :active, type: :boolean encrypts :salary, type: :integer encrypts :latitude, type: :float encrypts :video, type: :binary encrypts :properties, type: :json encrypts :settings, type: :hash encrypts :messages, type: :array encrypts :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. ```ruby class User < ApplicationRecord serialize :properties, JSON store :settings, accessors: [:color, :homepage] attribute :configuration, CustomType.new encrypts :properties, :settings, :configuration end ``` For [StoreModel](https://github.com/DmitryTsepelev/store_model), use: ```ruby class User < ApplicationRecord encrypts :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](https://github.com/ankane/blind_index). #### Fixtures You can use encrypted attributes in fixtures with: ```yml 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: ```ruby class User < ApplicationRecord encrypts :email, migrating: true end ``` Backfill the data in the Rails console: ```ruby Lockbox.migrate(User) ``` Then update the model to the desired state: ```ruby class User < ApplicationRecord encrypts :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. ```ruby 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 ```ruby 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 ```ruby 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: ```ruby 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: ```ruby class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[6.1] def change add_column :action_text_rich_texts, :body_ciphertext, :text end end ``` Create `config/initializers/lockbox.rb` with: ```ruby Lockbox.encrypts_action_text_body(migrating: true) ``` Migrate existing data: ```ruby Lockbox.migrate(ActionText::RichText) ``` Update the initializer: ```ruby 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: ```ruby class User field :email_ciphertext, type: String encrypts :email end ``` You can use `email` just like any other attribute. ```ruby User.create!(email: "hi@example.org") ``` If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index). You can [migrate existing data](#migrating-existing-data) similarly to Active Record. ## Active Storage Add to your model: ```ruby class User < ApplicationRecord has_one_attached :license encrypts_attached :license end ``` Works with multiple attachments as well. ```ruby 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. ```ruby def license user = User.find(params[:id]) send_data user.license.download, type: user.license.content_type end ``` #### Migrating Existing Files Lockbox makes it easy to encrypt existing files without downtime. Add to your model: ```ruby class User < ApplicationRecord encrypts_attached :license, migrating: true end ``` Migrate existing files: ```ruby Lockbox.migrate(User) ``` Then update the model to the desired state: ```ruby class User < ApplicationRecord encrypts_attached :license end ``` ## CarrierWave Add to your uploader: ```ruby class LicenseUploader < CarrierWave::Uploader::Base encrypt end ``` Encryption is applied to all versions after processing. You can mount the uploader [as normal](https://github.com/carrierwaveuploader/carrierwave#activerecord). With Active Record, this involves creating a migration: ```ruby class AddLicenseToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :license, :string end end ``` And updating the model: ```ruby class User < ApplicationRecord mount_uploader :license, LicenseUploader end ``` To serve encrypted files, use a controller action. ```ruby def license user = User.find(params[:id]) send_data user.license.read, type: user.license.content_type end ``` #### Migrating Existing Files Encrypt existing files without downtime. Create a new encrypted uploader: ```ruby 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: ```ruby 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: ```ruby 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: ```ruby 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: ```ruby class User < ApplicationRecord include LicenseUploader::Attachment(:license) end ``` And encrypt in a controller (or background job, etc) with: ```ruby 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. ```ruby 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 ``` #### Non-Models Generate a key ```ruby key = Lockbox.generate_key ``` Create a lockbox ```ruby lockbox = Lockbox.new(key: key) ``` Encrypt files before passing them to Shrine ```ruby LicenseUploader.upload(lockbox.encrypt_io(file), :store) ``` And decrypt them after reading ```ruby lockbox.decrypt(uploaded_file.read) ``` ## Local Files Generate a key ```ruby key = Lockbox.generate_key ``` Create a lockbox ```ruby lockbox = Lockbox.new(key: key) ``` Encrypt ```ruby ciphertext = lockbox.encrypt(File.binread("file.txt")) ``` Decrypt ```ruby lockbox.decrypt(ciphertext) ``` ## Strings Generate a key ```ruby key = Lockbox.generate_key ``` Create a lockbox ```ruby lockbox = Lockbox.new(key: key, encode: true) ``` Encrypt ```ruby ciphertext = lockbox.encrypt("hello") ``` Decrypt ```ruby 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. ### Active Record & Mongoid Update your model: ```ruby class User < ApplicationRecord encrypts :email, previous_versions: [{master_key: previous_key}] end ``` To rotate existing records, use: ```ruby Lockbox.rotate(User, attributes: [:email]) ``` Once all records are rotated, you can remove `previous_versions` from the model. ### Action Text Update your initializer: ```ruby Lockbox.encrypts_action_text_body(previous_versions: [{master_key: previous_key}]) ``` To rotate existing records, use: ```ruby Lockbox.rotate(ActionText::RichText, attributes: [:body]) ``` Once all records are rotated, you can remove `previous_versions` from the initializer. ### Active Storage Update your model: ```ruby class User < ApplicationRecord encrypts_attached :license, previous_versions: [{master_key: previous_key}] end ``` To rotate existing files, use: ```ruby User.with_attached_license.find_each do |user| user.license.rotate_encryption! end ``` Once all files are rotated, you can remove `previous_versions` from the model. ### CarrierWave Update your model: ```ruby class LicenseUploader < CarrierWave::Uploader::Base encrypt previous_versions: [{master_key: previous_key}] end ``` To rotate existing files, use: ```ruby User.find_each do |user| user.license.rotate_encryption! end ``` For multiple files, use: ```ruby User.find_each do |user| user.licenses.map(&:rotate_encryption!) end ``` Once all files are rotated, you can remove `previous_versions` from the model. ### Local Files & Strings For local files and strings, use: ```ruby 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. ```sh rails generate lockbox:audits rails db:migrate ``` Then create an audit wherever a user can view data: ```ruby 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: ```ruby 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](https://en.wikipedia.org/wiki/AES_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](https://www.cryptologie.net/article/402/is-symmetric-security-solved/), 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](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium). For Homebrew, use: ```sh brew install libsodium ``` And add to your Gemfile: ```ruby gem 'rbnacl' ``` Then add to your model: ```ruby class User < ApplicationRecord encrypts :email, algorithm: "xsalsa20" end ``` Make it the default with: ```ruby Lockbox.default_options = {algorithm: "xsalsa20"} ``` You can also pass an algorithm to `previous_versions` for key rotation. #### XSalsa20 Deployment ##### Heroku Heroku [comes with libsodium](https://devcenter.heroku.com/articles/stack-packages) preinstalled. ##### Ubuntu For Ubuntu 20.04 and 18.04, use: ```sh sudo apt-get install libsodium23 ``` For Ubuntu 16.04, use: ```sh sudo apt-get install libsodium18 ``` ##### GitHub Actions For Ubuntu 20.04 and 18.04, use: ```yml - name: Install Libsodium run: sudo apt-get update && sudo apt-get install libsodium23 ``` For Ubuntu 16.04, use: ```yml - name: Install Libsodium run: sudo apt-get update && sudo apt-get install libsodium18 ``` ##### Travis CI On Bionic, add to `.travis.yml`: ```yml addons: apt: packages: - libsodium23 ``` On Xenial, add to `.travis.yml`: ```yml addons: apt: packages: - libsodium18 ``` ##### CircleCI Add a step to `.circleci/config.yml`: ```yml - run: name: install Libsodium command: sudo apt-get install -y libsodium18 ``` ## Hybrid Cryptography [Hybrid cryptography](https://en.wikipedia.org/wiki/Hybrid_cryptosystem) 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: ```ruby Lockbox.generate_key_pair ``` Store the keys with your other secrets. Then use: ```ruby class User < ApplicationRecord encrypts :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](https://ciphersweet.paragonie.com/internals/key-hierarchy). The table name and column/uploader name are both used in this process. You can get an individual key with: ```ruby Lockbox.attribute_key(table: "users", attribute: "email_ciphertext") ``` To rename a table with encrypted columns/uploaders, use: ```ruby class User < ApplicationRecord encrypts :email, key_table: "original_table" end ``` To rename an encrypted column itself, use: ```ruby class User < ApplicationRecord encrypts :email, key_attribute: "original_column" end ``` ### Per Field/Uploader To set a key for an individual field/uploader, use a string: ```ruby class User < ApplicationRecord encrypts :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"] end ``` Or a proc: ```ruby class User < ApplicationRecord encrypts :email, key: -> { code } end ``` ### Per Record To use a different key for each record, use a symbol: ```ruby class User < ApplicationRecord encrypts :email, key: :some_method end ``` Or a proc: ```ruby class User < ApplicationRecord encrypts :email, key: -> { some_method } end ``` ## Key Management You can use a key management service to manage your keys with [KMS Encrypted](https://github.com/ankane/kms_encrypted). For Active Record and Mongoid, use: ```ruby class User < ApplicationRecord encrypts :email, key: :kms_key end ``` For Action Text, use: ```ruby 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: ```ruby class User < ApplicationRecord encrypts_attached :license, key: :kms_key end ``` For CarrierWave, use: ```ruby 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. ```ruby 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. ```ruby 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](https://en.wikipedia.org/wiki/Padding_(cryptography)#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. ```ruby box.encrypt("length15status!").bytesize # 44 box.encrypt("length16status!!").bytesize # 60 ``` Change the block size with: ```ruby 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. ```ruby lockbox = Lockbox.new(key: key) ciphertext = lockbox.encrypt(message, associated_data: "somecontext") ``` Without the same context, decryption will fail. ```ruby lockbox.decrypt(ciphertext, associated_data: "somecontext") # success lockbox.decrypt(ciphertext, associated_data: "othercontext") # fails ``` ## Binary Columns You can use `binary` columns for the ciphertext instead of `text` columns. ```ruby class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :email_ciphertext, :binary end end ``` Disable Base64 encoding to save space. ```ruby class User < ApplicationRecord encrypts :email, encode: false end ``` ## 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](docs/Compatibility.md). For XSalsa20, use the appropriate [Libsodium library](https://libsodium.gitbook.io/doc/bindings_for_other_languages). ## 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: ```ruby class User < ApplicationRecord attr_encrypted :name, key: key attr_encrypted :email, key: key end ``` Create a migration with: ```ruby class MigrateToLockbox < ActiveRecord::Migration[6.1] def change add_column :users, :name_ciphertext, :text add_column :users, :email_ciphertext, :text end end ``` And add `encrypts` to your model with the `migrating` option: ```ruby class User < ApplicationRecord encrypts :name, :email, migrating: true end ``` Then run: ```ruby Lockbox.migrate(User) ``` Once all records are migrated, remove the `migrating` option and the previous model code (the `attr_encrypted` methods in this example). ```ruby class User < ApplicationRecord encrypts :name, :email end ``` Then remove the previous gem from your Gemfile and drop its columns. ```ruby class RemovePreviousEncryptedColumns < ActiveRecord::Migration[6.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 ### 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: ```ruby 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 ``` ### 0.3.6 0.3.6 makes content type detection more reliable for Active Storage. You can check and update the content type of existing files with: ```ruby User.with_attached_license.find_each do |user| next unless user.license.attached? license = user.license content_type = Marcel::MimeType.for(license.download, name: license.filename.to_s) if content_type != license.content_type license.update!(content_type: content_type) end end ``` ### 0.2.0 0.2.0 brings a number of improvements. Here are a few to be aware of: - Added `encrypts` method for database fields - Added support for XSalsa20 - `attached_encrypted` is deprecated in favor of `encrypts_attached`. #### Optional To switch to a master key, generate a key: ```ruby Lockbox.generate_key ``` And set `ENV["LOCKBOX_MASTER_KEY"]` or `Lockbox.master_key`. Update your model: ```ruby class User < ApplicationRecord encrypts_attached :license, previous_versions: [{key: key}] end ``` New uploads will be encrypted with the new key. You can rotate existing records with: ```ruby User.unscoped.find_each do |user| user.license.rotate_encryption! end ``` Once that’s complete, update your model: ```ruby class User < ApplicationRecord encrypts_attached :license end ``` ## History View the [changelog](https://github.com/ankane/lockbox/blob/master/CHANGELOG.md) ## Contributing Everyone is encouraged to help improve this project. Here are a few ways you can help: - [Report bugs](https://github.com/ankane/lockbox/issues) - Fix bugs and [submit pull requests](https://github.com/ankane/lockbox/pulls) - Write, clarify, or fix documentation - Suggest or add new features To get started with development, [install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) and run: ```sh 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](https://github.com/ankane). lockbox-0.6.4/lockbox.gemspec0000644000004100000410000000316014042066300016213 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: lockbox 0.6.4 ruby lib Gem::Specification.new do |s| s.name = "lockbox".freeze s.version = "0.6.4" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] s.authors = ["Andrew Kane".freeze] s.date = "2021-04-06" s.email = "andrew@ankane.org".freeze s.files = ["CHANGELOG.md".freeze, "LICENSE.txt".freeze, "README.md".freeze, "SECURITY.md".freeze, "lib/generators/lockbox/audits_generator.rb".freeze, "lib/generators/lockbox/templates/migration.rb.tt".freeze, "lib/generators/lockbox/templates/model.rb.tt".freeze, "lib/lockbox.rb".freeze, "lib/lockbox/active_storage_extensions.rb".freeze, "lib/lockbox/aes_gcm.rb".freeze, "lib/lockbox/box.rb".freeze, "lib/lockbox/calculations.rb".freeze, "lib/lockbox/carrier_wave_extensions.rb".freeze, "lib/lockbox/encryptor.rb".freeze, "lib/lockbox/io.rb".freeze, "lib/lockbox/key_generator.rb".freeze, "lib/lockbox/log_subscriber.rb".freeze, "lib/lockbox/migrator.rb".freeze, "lib/lockbox/model.rb".freeze, "lib/lockbox/padding.rb".freeze, "lib/lockbox/railtie.rb".freeze, "lib/lockbox/utils.rb".freeze, "lib/lockbox/version.rb".freeze] s.homepage = "https://github.com/ankane/lockbox".freeze s.licenses = ["MIT".freeze] s.required_ruby_version = Gem::Requirement.new(">= 2.4".freeze) s.rubygems_version = "2.7.6.2".freeze s.summary = "Modern encryption for Ruby and Rails".freeze end lockbox-0.6.4/CHANGELOG.md0000644000004100000410000001151014042066300015014 0ustar www-datawww-data## 0.6.4 (2021-04-05) - Fixed in place changes in callbacks - Fixed `[]` method for encrypted attributes ## 0.6.3 (2021-03-30) - Fixed empty arrays and hashes - Fixed content type for CarrierWave 2.2.1 ## 0.6.2 (2021-02-08) - Added `inet` type - Fixed error when `lockbox` key in Rails credentials has a string value - Fixed deprecation warning with Active Record 6.1 ## 0.6.1 (2020-12-03) - Added integration with Rails credentials - Fixed in place changes for Active Record 6.1 - Fixed error with `content_type` method for CarrierWave < 2 ## 0.6.0 (2020-12-03) - Added `encrypted` flag to Active Storage metadata - Added encrypted columns to `filter_attributes` - Improved `inspect` method ## 0.5.0 (2020-11-22) - Improved error messages for hybrid cryptography - Changed warning to error when no attributes specified - Fixed issue with `pluck` when migrating - Fixed error with `key_table` and `key_attribute` options with `previous_versions` ## 0.4.9 (2020-10-01) - Added `key_table` and `key_attribute` options to `previous_versions` - Added `encrypted_attribute` option - Added support for encrypting empty string - Improved `inspect` for models with encrypted attributes ## 0.4.8 (2020-08-30) - Added `key_table` and `key_attribute` options - Added warning when no attributes specified - Fixed error when Active Support partially loaded ## 0.4.7 (2020-08-18) - Added `lockbox_options` method to encrypted CarrierWave uploaders - Improved attribute loading when no decryption key specified ## 0.4.6 (2020-07-02) - Added support for `update_column` and `update_columns` ## 0.4.5 (2020-06-26) - Improved error message for non-string values - Fixed error with migrating Action Text - Fixed error with migrating serialized attributes ## 0.4.4 (2020-06-23) - Added support for `pluck` ## 0.4.3 (2020-05-26) - Improved error message for bad key length - Fixed missing attribute error ## 0.4.2 (2020-05-11) - Added experimental support for migrating Active Storage files - Fixed `metadata` support for Active Storage ## 0.4.1 (2020-05-08) - Added support for Action Text - Added warning if unencrypted column exists and not migrating ## 0.4.0 (2020-05-03) - Load encrypted attributes when `attributes` called - Added support for migrating and rotating relations - Removed deprecated `attached_encrypted` method - Removed legacy `attr_encrypted` encryptor ## 0.3.7 (2020-04-20) - Added Active Support notifications for Active Storage and Carrierwave ## 0.3.6 (2020-04-19) - Fixed content type detection for Active Storage and CarrierWave - Fixed decryption with Active Storage 6 and `attachment.open` ## 0.3.5 (2020-04-13) - Added `array` type - Fixed serialize error with `json` type - Fixed empty hash with `hash` type ## 0.3.4 (2020-04-05) - Fixed `migrating: true` with `validate: false` - Fixed serialization when migrating certain column types ## 0.3.3 (2020-02-16) - Improved performance of `rotate` for attributes with blind indexes - Added warning when decrypting previous value fails ## 0.3.2 (2020-02-14) - Added `encode` option to `Lockbox::Encryptor` - Added support for `master_key` in `previous_versions` - Added `Lockbox.rotate` method - Improved performance of `migrate` method - Added generator for audits ## 0.3.1 (2019-12-26) - Fixed encoding for `encrypt_io` and `decrypt_io` in Ruby 2.7 - Fixed deprecation warnings in Ruby 2.7 ## 0.3.0 (2019-12-22) - Added support for custom types - Added support for virtual attributes - Made many Mongoid methods consistent with unencrypted columns - Made `was` and `in_database` methods consistent with unencrypted columns before an update - Made `restore` methods restore ciphertext - Fixed virtual attribute being saved with `nil` for Mongoid - Changed `Lockbox` to module ## 0.2.5 (2019-12-14) - Made `model.attribute?` consistent with unencrypted columns - Added `decrypt_str` method - Improved fixtures for attributes with `type` option ## 0.2.4 (2019-08-16) - Added support for Mongoid - Added `encrypt_io` and `decrypt_io` methods - Made it easier to rotate algorithms with master key - Fixed error with migrate and default scope - Fixed encryption with Active Storage 6 and `record.create!` ## 0.2.3 (2019-07-31) - Added time type - Added support for rotating padding with same key - Fixed `OpenSSL::KDF` error on some platforms - Fixed UTF-8 error ## 0.2.2 (2019-07-24) - Fixed error with models that have attachments but no encrypted attachments ## 0.2.1 (2019-07-22) - Added support for types - Added support for serialized attributes - Added support for padding - Added `encode` option for binary columns ## 0.2.0 (2019-07-08) - Added `encrypts` method for database fields - Added `encrypts_attached` method - Added `generate_key` method - Added support for XSalsa20 ## 0.1.1 (2019-02-28) - Added support for hybrid cryptography - Added support for database fields ## 0.1.0 (2019-01-02) - First release lockbox-0.6.4/lib/0000755000004100000410000000000014042066300013753 5ustar www-datawww-datalockbox-0.6.4/lib/generators/0000755000004100000410000000000014042066300016124 5ustar www-datawww-datalockbox-0.6.4/lib/generators/lockbox/0000755000004100000410000000000014042066300017565 5ustar www-datawww-datalockbox-0.6.4/lib/generators/lockbox/templates/0000755000004100000410000000000014042066300021563 5ustar www-datawww-datalockbox-0.6.4/lib/generators/lockbox/templates/migration.rb.tt0000644000004100000410000000053714042066300024534 0ustar www-datawww-dataclass <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> def change create_table :lockbox_audits do |t| t.references :subject, polymorphic: true t.references :viewer, polymorphic: true t.<%= data_type %> :data t.string :context t.string :ip t.datetime :created_at end end end lockbox-0.6.4/lib/generators/lockbox/templates/model.rb.tt0000644000004100000410000000027214042066300023637 0ustar www-datawww-dataclass LockboxAudit < ApplicationRecord belongs_to :subject, polymorphic: true belongs_to :viewer, polymorphic: true<% if data_type == "text" %> serialize :data, JSON<% end %> end lockbox-0.6.4/lib/generators/lockbox/audits_generator.rb0000644000004100000410000000212114042066300023445 0ustar www-datawww-datarequire "rails/generators/active_record" module Lockbox module Generators class AuditsGenerator < Rails::Generators::Base include ActiveRecord::Generators::Migration source_root File.join(__dir__, "templates") def copy_migration migration_template "migration.rb", "db/migrate/create_lockbox_audits.rb", migration_version: migration_version template "model.rb", "app/models/lockbox_audit.rb" end def migration_version "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" end def data_type case adapter when /postg/i # postgres, postgis "jsonb" when /mysql/i "json" else "text" end end # use connection_config instead of connection.adapter # so database connection isn't needed def adapter if ActiveRecord::VERSION::STRING.to_f >= 6.1 ActiveRecord::Base.connection_db_config.adapter.to_s else ActiveRecord::Base.connection_config[:adapter].to_s end end end end end lockbox-0.6.4/lib/lockbox.rb0000644000004100000410000000633014042066300015743 0ustar www-datawww-data# stdlib require "base64" require "openssl" require "securerandom" # modules require "lockbox/aes_gcm" require "lockbox/box" require "lockbox/calculations" require "lockbox/encryptor" require "lockbox/key_generator" require "lockbox/io" require "lockbox/migrator" require "lockbox/model" require "lockbox/padding" require "lockbox/utils" require "lockbox/version" # integrations require "lockbox/carrier_wave_extensions" if defined?(CarrierWave) require "lockbox/railtie" if defined?(Rails) if defined?(ActiveSupport::LogSubscriber) require "lockbox/log_subscriber" Lockbox::LogSubscriber.attach_to :lockbox end if defined?(ActiveSupport.on_load) ActiveSupport.on_load(:active_record) do # TODO raise error in 0.7.0 if ActiveRecord::VERSION::STRING.to_f <= 5.0 warn "Active Record version (#{ActiveRecord::VERSION::STRING}) not supported in this version of Lockbox (#{Lockbox::VERSION})" end extend Lockbox::Model extend Lockbox::Model::Attached # alias_method is private in Ruby < 2.5 singleton_class.send(:alias_method, :encrypts, :lockbox_encrypts) if ActiveRecord::VERSION::MAJOR < 7 ActiveRecord::Calculations.prepend Lockbox::Calculations end ActiveSupport.on_load(:mongoid) do Mongoid::Document::ClassMethods.include(Lockbox::Model) # alias_method is private in Ruby < 2.5 Mongoid::Document::ClassMethods.send(:alias_method, :encrypts, :lockbox_encrypts) end end module Lockbox class Error < StandardError; end class DecryptionError < Error; end class PaddingError < Error; end autoload :Audit, "lockbox/audit" extend Padding class << self attr_accessor :default_options attr_writer :master_key end self.default_options = {} def self.master_key @master_key ||= ENV["LOCKBOX_MASTER_KEY"] end def self.migrate(relation, batch_size: 1000, restart: false) Migrator.new(relation, batch_size: batch_size).migrate(restart: restart) end def self.rotate(relation, batch_size: 1000, attributes:) Migrator.new(relation, batch_size: batch_size).rotate(attributes: attributes) end def self.generate_key SecureRandom.hex(32) end def self.generate_key_pair require "rbnacl" # encryption and decryption servers exchange public keys # this produces smaller ciphertext than sealed box alice = RbNaCl::PrivateKey.generate bob = RbNaCl::PrivateKey.generate # alice is sending message to bob # use bob first in both cases to prevent keys being swappable { encryption_key: to_hex(bob.public_key.to_bytes + alice.to_bytes), decryption_key: to_hex(bob.to_bytes + alice.public_key.to_bytes) } end def self.attribute_key(table:, attribute:, master_key: nil, encode: true) master_key ||= Lockbox.master_key raise ArgumentError, "Missing master key" unless master_key key = Lockbox::KeyGenerator.new(master_key).attribute_key(table: table, attribute: attribute) key = to_hex(key) if encode key end def self.to_hex(str) str.unpack("H*").first end def self.new(**options) Encryptor.new(**options) end def self.encrypts_action_text_body(**options) ActiveSupport.on_load(:action_text_rich_text) do ActionText::RichText.lockbox_encrypts :body, **options end end end lockbox-0.6.4/lib/lockbox/0000755000004100000410000000000014042066300015414 5ustar www-datawww-datalockbox-0.6.4/lib/lockbox/active_storage_extensions.rb0000644000004100000410000001152514042066300023223 0ustar www-datawww-data# Ideally encryption and decryption would happen at the blob/service level. # However, Active Storage < 6.1 only supports a single service (per environment). # This means all attachments need to be encrypted or none of them, # which is often not practical. # # Active Storage 6.1 adds support for multiple services, which changes this. # We could have a Lockbox service: # # lockbox: # service: Lockbox # backend: local # delegate to another service, like mirror service # key: ... # Lockbox options # # However, the checksum is computed *and stored on the blob* # before the file is passed to the service. # We don't want the MD5 checksum of the plaintext stored in the database. # # Instead, we encrypt and decrypt at the attachment level, # and we define encryption settings at the model level. module Lockbox module ActiveStorageExtensions module Attached protected def encrypted? # could use record_type directly # but record should already be loaded most of the time Utils.encrypted?(record, name) end def encrypt_attachable(attachable) Utils.encrypt_attachable(record, name, attachable) end end module AttachedOne if ActiveStorage::VERSION::MAJOR < 6 def attach(attachable) attachable = encrypt_attachable(attachable) if encrypted? super(attachable) end end def rotate_encryption! raise "Not encrypted" unless encrypted? attach(Utils.rebuild_attachable(self)) if attached? true end end module AttachedMany if ActiveStorage::VERSION::MAJOR < 6 def attach(*attachables) if encrypted? attachables = attachables.flatten.collect do |attachable| encrypt_attachable(attachable) end end super(attachables) end end def rotate_encryption! raise "Not encrypted" unless encrypted? # must call to_a - do not change previous_attachments = attachments.to_a attachables = previous_attachments.map do |attachment| Utils.rebuild_attachable(attachment) end ActiveStorage::Attachment.transaction do attach(attachables) previous_attachments.each(&:purge) end attachments.reload true end end module CreateOne def initialize(name, record, attachable) # this won't encrypt existing blobs # ideally we'd check metadata for the encrypted flag # and disallow unencrypted blobs # since they'll raise an error on decryption # but earlier versions of Lockbox won't have it attachable = Lockbox::Utils.encrypt_attachable(record, name, attachable) if Lockbox::Utils.encrypted?(record, name) && !attachable.is_a?(ActiveStorage::Blob) super(name, record, attachable) end end module Attachment def download result = super options = Utils.encrypted_options(record, name) # only trust the metadata when migrating # as earlier versions of Lockbox won't have it # and it's not a good practice to trust modifiable data encrypted = options && (!options[:migrating] || blob.metadata["encrypted"]) if encrypted result = Utils.decrypt_result(record, name, options, result) end result end def variant(*args) raise Lockbox::Error, "Variant not supported for encrypted files" if Utils.encrypted_options(record, name) super end def preview(*args) raise Lockbox::Error, "Preview not supported for encrypted files" if Utils.encrypted_options(record, name) super end if ActiveStorage::VERSION::MAJOR >= 6 def open(**options) blob.open(**options) do |file| options = Utils.encrypted_options(record, name) # only trust the metadata when migrating # as earlier versions of Lockbox won't have it # and it's not a good practice to trust modifiable data encrypted = options && (!options[:migrating] || blob.metadata["encrypted"]) if encrypted result = Utils.decrypt_result(record, name, options, file.read) file.rewind # truncate may not be available on all platforms # according to the Ruby docs # may need to create a new temp file instead file.truncate(0) file.write(result) file.rewind end yield file end end end end module Blob private def extract_content_type(io) if io.is_a?(Lockbox::IO) && io.extracted_content_type io.extracted_content_type else super end end end end end lockbox-0.6.4/lib/lockbox/log_subscriber.rb0000644000004100000410000000104314042066300020743 0ustar www-datawww-datamodule Lockbox class LogSubscriber < ActiveSupport::LogSubscriber def encrypt_file(event) return unless logger.debug? payload = event.payload name = "Encrypt File (#{event.duration.round(1)}ms)" debug " #{color(name, YELLOW, true)} Encrypted #{payload[:name]}" end def decrypt_file(event) return unless logger.debug? payload = event.payload name = "Decrypt File (#{event.duration.round(1)}ms)" debug " #{color(name, YELLOW, true)} Decrypted #{payload[:name]}" end end end lockbox-0.6.4/lib/lockbox/version.rb0000644000004100000410000000004714042066300017427 0ustar www-datawww-datamodule Lockbox VERSION = "0.6.4" end lockbox-0.6.4/lib/lockbox/key_generator.rb0000644000004100000410000000233214042066300020577 0ustar www-datawww-datamodule Lockbox class KeyGenerator def initialize(master_key) @master_key = master_key end # pattern ported from CipherSweet # https://ciphersweet.paragonie.com/internals/key-hierarchy def attribute_key(table:, attribute:) raise ArgumentError, "Missing table for key generation" if table.to_s.empty? raise ArgumentError, "Missing attribute for key generation" if attribute.to_s.empty? c = "\xB4"*32 hkdf(Lockbox::Utils.decode_key(@master_key, name: "Master key"), salt: table.to_s, info: "#{c}#{attribute}", length: 32, hash: "sha384") end private def hash_hmac(hash, ikm, salt) OpenSSL::HMAC.digest(hash, salt, ikm) end def hkdf(ikm, salt:, info:, length:, hash:) if defined?(OpenSSL::KDF.hkdf) return OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: length, hash: hash) end prk = hash_hmac(hash, ikm, salt) # empty binary string t = String.new last_block = String.new block_index = 1 while t.bytesize < length last_block = hash_hmac(hash, last_block + info + [block_index].pack("C"), prk) t << last_block block_index += 1 end t[0, length] end end end lockbox-0.6.4/lib/lockbox/padding.rb0000644000004100000410000000234614042066300017354 0ustar www-datawww-datamodule Lockbox module Padding PAD_FIRST_BYTE = "\x80".b PAD_ZERO_BYTE = "\x00".b def pad(str, **options) pad!(str.dup, **options) end def unpad(str, **options) unpad!(str.dup, **options) end # ISO/IEC 7816-4 # same as Libsodium # https://libsodium.gitbook.io/doc/padding # apply prior to encryption # note: current implementation does not # try to minimize side channels def pad!(str, size: 16) raise ArgumentError, "Invalid size" if size < 1 str.force_encoding(Encoding::BINARY) pad_length = size - 1 pad_length -= str.bytesize % size str << PAD_FIRST_BYTE pad_length.times do str << PAD_ZERO_BYTE end str end # note: current implementation does not # try to minimize side channels def unpad!(str, size: 16) raise ArgumentError, "Invalid size" if size < 1 str.force_encoding(Encoding::BINARY) i = 1 while i <= size case str[-i] when PAD_ZERO_BYTE i += 1 when PAD_FIRST_BYTE str.slice!(-i..-1) return str else break end end raise Lockbox::PaddingError, "Invalid padding" end end end lockbox-0.6.4/lib/lockbox/box.rb0000644000004100000410000000710114042066300016530 0ustar www-datawww-datamodule Lockbox class Box def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false) raise ArgumentError, "Cannot pass both key and encryption/decryption key" if key && (encryption_key || decryption_key) key = Lockbox::Utils.decode_key(key) if key encryption_key = Lockbox::Utils.decode_key(encryption_key, size: 64) if encryption_key decryption_key = Lockbox::Utils.decode_key(decryption_key, size: 64) if decryption_key algorithm ||= "aes-gcm" case algorithm when "aes-gcm" raise ArgumentError, "Missing key" unless key @box = AES_GCM.new(key) when "xchacha20" raise ArgumentError, "Missing key" unless key require "rbnacl" @box = RbNaCl::AEAD::XChaCha20Poly1305IETF.new(key) when "xsalsa20" raise ArgumentError, "Missing key" unless key require "rbnacl" @box = RbNaCl::SecretBoxes::XSalsa20Poly1305.new(key) when "hybrid" raise ArgumentError, "Missing key" unless encryption_key || decryption_key require "rbnacl" @encryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(encryption_key.slice(0, 32), encryption_key.slice(32..-1)) if encryption_key @decryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(decryption_key.slice(32..-1), decryption_key.slice(0, 32)) if decryption_key else raise ArgumentError, "Unknown algorithm: #{algorithm}" end @algorithm = algorithm @padding = padding == true ? 16 : padding end def encrypt(message, associated_data: nil) message = Lockbox.pad(message, size: @padding) if @padding case @algorithm when "hybrid" raise ArgumentError, "No encryption key set" unless defined?(@encryption_box) raise ArgumentError, "Associated data not supported with this algorithm" if associated_data nonce = generate_nonce(@encryption_box) ciphertext = @encryption_box.encrypt(nonce, message) when "xsalsa20" raise ArgumentError, "Associated data not supported with this algorithm" if associated_data nonce = generate_nonce(@box) ciphertext = @box.encrypt(nonce, message) else nonce = generate_nonce(@box) ciphertext = @box.encrypt(nonce, message, associated_data) end nonce + ciphertext end def decrypt(ciphertext, associated_data: nil) message = case @algorithm when "hybrid" raise ArgumentError, "No decryption key set" unless defined?(@decryption_box) raise ArgumentError, "Associated data not supported with this algorithm" if associated_data nonce, ciphertext = extract_nonce(@decryption_box, ciphertext) @decryption_box.decrypt(nonce, ciphertext) when "xsalsa20" raise ArgumentError, "Associated data not supported with this algorithm" if associated_data nonce, ciphertext = extract_nonce(@box, ciphertext) @box.decrypt(nonce, ciphertext) else nonce, ciphertext = extract_nonce(@box, ciphertext) @box.decrypt(nonce, ciphertext, associated_data) end message = Lockbox.unpad!(message, size: @padding) if @padding message end # protect key for xsalsa20, xchacha20, and hybrid def inspect to_s end private def generate_nonce(box) SecureRandom.random_bytes(box.nonce_bytes) end def extract_nonce(box, bytes) nonce_bytes = box.nonce_bytes nonce = bytes.slice(0, nonce_bytes) [nonce, bytes.slice(nonce_bytes..-1)] end end end lockbox-0.6.4/lib/lockbox/migrator.rb0000644000004100000410000001410414042066300017565 0ustar www-datawww-datamodule Lockbox class Migrator def initialize(relation, batch_size:) @relation = relation @transaction = @relation.respond_to?(:transaction) @batch_size = batch_size end def model @model ||= @relation end def rotate(attributes:) fields = {} attributes.each do |a| # use key instead of v[:attribute] to make it more intuitive when migrating: true field = model.lockbox_attributes[a] raise ArgumentError, "Bad attribute: #{a}" unless field fields[a] = field end perform(fields: fields, rotate: true) end # TODO add attributes option def migrate(restart:) fields = model.respond_to?(:lockbox_attributes) ? model.lockbox_attributes.select { |k, v| v[:migrating] } : {} # need blind indexes for building relation blind_indexes = model.respond_to?(:blind_indexes) ? model.blind_indexes.select { |k, v| v[:migrating] } : {} attachments = model.respond_to?(:lockbox_attachments) ? model.lockbox_attachments.select { |k, v| v[:migrating] } : {} perform(fields: fields, blind_indexes: blind_indexes, restart: restart) if fields.any? || blind_indexes.any? perform_attachments(attachments: attachments, restart: restart) if attachments.any? end private def perform_attachments(attachments:, restart:) relation = base_relation # eager load attachments attachments.each_key do |k| relation = relation.send("with_attached_#{k}") end each_batch(relation) do |records| records.each do |record| attachments.each_key do |k| attachment = record.send(k) if attachment.attached? if attachment.is_a?(ActiveStorage::Attached::One) unless attachment.metadata["encrypted"] attachment.rotate_encryption! end else unless attachment.all? { |a| a.metadata["encrypted"] } attachment.rotate_encryption! end end end end end end end def perform(fields:, blind_indexes: [], restart: true, rotate: false) relation = base_relation unless restart attributes = fields.map { |_, v| v[:encrypted_attribute] } attributes += blind_indexes.map { |_, v| v[:bidx_attribute] } if ar_relation?(relation) base_relation = relation.unscoped or_relation = relation.unscoped attributes.each_with_index do |attribute, i| or_relation = if i == 0 base_relation.where(attribute => nil) else or_relation.or(base_relation.where(attribute => nil)) end end relation = relation.merge(or_relation) else relation.merge(relation.unscoped.or(attributes.map { |a| {a => nil} })) end end each_batch(relation) do |records| migrate_records(records, fields: fields, blind_indexes: blind_indexes, restart: restart, rotate: rotate) end end def each_batch(relation) if relation.respond_to?(:find_in_batches) relation.find_in_batches(batch_size: @batch_size) do |records| yield records end else # https://github.com/karmi/tire/blob/master/lib/tire/model/import.rb # use cursor for Mongoid records = [] relation.all.each do |record| records << record if records.length == @batch_size yield records records = [] end end yield records if records.any? end end # there's a small chance for this process to read data, # another process to update the data, and # this process to write the now stale data # this time window can be reduced with smaller batch sizes # locking individual records could eliminate this # one option is: relation.in_batches { |batch| batch.lock } # which runs SELECT ... FOR UPDATE in Postgres def migrate_records(records, fields:, blind_indexes:, restart:, rotate:) # do computation outside of transaction # especially expensive blind index computation if rotate records.each do |record| fields.each do |k, v| # update encrypted attribute directly to skip blind index computation record.send("lockbox_direct_#{k}=", record.send(k)) end end else records.each do |record| if restart fields.each do |k, v| record.send("#{v[:encrypted_attribute]}=", nil) end blind_indexes.each do |k, v| record.send("#{v[:bidx_attribute]}=", nil) end end fields.each do |k, v| record.send("#{v[:attribute]}=", record.send(k)) unless record.send(v[:encrypted_attribute]) end # with Blind Index 2.0, bidx_attribute should be already set for each record blind_indexes.each do |k, v| record.send("compute_#{k}_bidx") unless record.send(v[:bidx_attribute]) end end end # don't need to save records that went from nil => nil records.select! { |r| r.changed? } if records.any? with_transaction do records.each do |record| record.save!(validate: false) end end end end def base_relation relation = @relation # unscope if passed a model unless ar_relation?(relation) || mongoid_relation?(relation) relation = relation.unscoped end # convert from possible class to ActiveRecord::Relation or Mongoid::Criteria relation.all end def ar_relation?(relation) defined?(ActiveRecord::Relation) && relation.is_a?(ActiveRecord::Relation) end def mongoid_relation?(relation) defined?(Mongoid::Criteria) && relation.is_a?(Mongoid::Criteria) end def with_transaction if @transaction @relation.transaction do yield end else yield end end end end lockbox-0.6.4/lib/lockbox/carrier_wave_extensions.rb0000644000004100000410000000732214042066300022675 0ustar www-datawww-datamodule Lockbox module CarrierWaveExtensions def encrypt(**options) class_eval do # uses same hook as process (before cache) # processing can be disabled, so better to keep separate before :cache, :encrypt define_singleton_method :lockbox_options do options end def encrypt(file) # safety check # see CarrierWave::Uploader::Cache#cache! raise Lockbox::Error, "Expected files to be equal. Please report an issue." unless file && @file && file == @file # processors in CarrierWave move updated file to current_path # however, this causes versions to use the processed file # we only want to change the file for the current version @file = CarrierWave::SanitizedFile.new(lockbox_notify("encrypt_file") { lockbox.encrypt_io(file) }) end # TODO safe to memoize? def read r = super lockbox_notify("decrypt_file") { lockbox.decrypt(r) } if r end # use size of plaintext since read and content type use plaintext def size read.bytesize end def content_type if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("2.2.1") # based on CarrierWave::SanitizedFile#marcel_magic_content_type Marcel::Magic.by_magic(read).try(:type) || "invalid/invalid" elsif CarrierWave::VERSION.to_i >= 2 # based on CarrierWave::SanitizedFile#mime_magic_content_type MimeMagic.by_magic(read).try(:type) || "invalid/invalid" else # uses filename super end end # disable processing since already processed def rotate_encryption! io = Lockbox::IO.new(read) io.original_filename = file.filename previous_value = enable_processing begin self.enable_processing = false store!(io) ensure self.enable_processing = previous_value end end private define_method :lockbox do @lockbox ||= begin table = model ? model.class.table_name : "_uploader" attribute = lockbox_name Utils.build_box(self, options, table, attribute) end end # for mounted uploaders, use mounted name # for others, use uploader name def lockbox_name if mounted_as mounted_as.to_s else uploader = self while uploader.parent_version uploader = uploader.parent_version end uploader.class.name.sub(/Uploader\z/, "").underscore end end # Active Support notifications so it's easier # to see when files are encrypted and decrypted def lockbox_notify(type) if defined?(ActiveSupport::Notifications) name = lockbox_name # get version version, _ = parent_version && parent_version.versions.find { |k, v| v == self } name = "#{name} #{version} version" if version ActiveSupport::Notifications.instrument("#{type}.lockbox", {name: name}) do yield end else yield end end end end end end if CarrierWave::VERSION.to_i > 2 raise "CarrierWave version (#{CarrierWave::VERSION}) not supported in this version of Lockbox (#{Lockbox::VERSION})" elsif CarrierWave::VERSION.to_i < 1 # TODO raise error in 0.7.0 warn "CarrierWave version (#{CarrierWave::VERSION}) not supported in this version of Lockbox (#{Lockbox::VERSION})" end CarrierWave::Uploader::Base.extend(Lockbox::CarrierWaveExtensions) lockbox-0.6.4/lib/lockbox/model.rb0000644000004100000410000005742214042066300017053 0ustar www-datawww-datamodule Lockbox module Model def lockbox_encrypts(*attributes, **options) # support objects # case options[:type] # when Date # options[:type] = :date # when Time # options[:type] = :datetime # when JSON # options[:type] = :json # when Hash # options[:type] = :hash # when Array # options[:type] = :array # when String # options[:type] = :string # when Integer # options[:type] = :integer # when Float # options[:type] = :float # end custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize) valid_types = [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :binary, :json, :hash, :array, :inet] raise ArgumentError, "Unknown type: #{options[:type]}" unless custom_type || valid_types.include?(options[:type]) activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base raise ArgumentError, "Type not supported yet with Mongoid" if options[:type] && !activerecord raise ArgumentError, "No attributes specified" if attributes.empty? raise ArgumentError, "Cannot use key_attribute with multiple attributes" if options[:key_attribute] && attributes.size > 1 original_options = options.dup attributes.each do |name| # per attribute options # TODO use a different name options = original_options.dup # add default options encrypted_attribute = options.delete(:encrypted_attribute) || "#{name}_ciphertext" # migrating original_name = name.to_sym name = "migrated_#{name}" if options[:migrating] name = name.to_sym options[:attribute] = name.to_s options[:encrypted_attribute] = encrypted_attribute options[:encode] = true unless options.key?(:encode) encrypt_method_name = "generate_#{encrypted_attribute}" decrypt_method_name = "decrypt_#{encrypted_attribute}" class_eval do # Lockbox uses custom inspect # but this could be useful for other gems if activerecord && ActiveRecord::VERSION::MAJOR >= 6 # only add virtual attribute # need to use regexp since strings do partial matching # also, need to use += instead of << self.filter_attributes += [/\A#{Regexp.escape(options[:attribute])}\z/] end @lockbox_attributes ||= {} if @lockbox_attributes.empty? def self.lockbox_attributes parent_attributes = if superclass.respond_to?(:lockbox_attributes) superclass.lockbox_attributes else {} end parent_attributes.merge(@lockbox_attributes || {}) end # use same approach as activerecord serialization def serializable_hash(options = nil) options = options.try(:dup) || {} options[:except] = Array(options[:except]) options[:except] += self.class.lockbox_attributes.flat_map { |_, v| [v[:attribute], v[:encrypted_attribute]] } super(options) end # maintain order # replace ciphertext attributes w/ virtual attributes (filtered) def inspect lockbox_attributes = {} lockbox_encrypted_attributes = {} self.class.lockbox_attributes.each do |_, lockbox_attribute| lockbox_attributes[lockbox_attribute[:attribute]] = true lockbox_encrypted_attributes[lockbox_attribute[:encrypted_attribute]] = lockbox_attribute[:attribute] end inspection = [] # use serializable_hash like Devise values = serializable_hash self.class.attribute_names.each do |k| next if !has_attribute?(k) || lockbox_attributes[k] # check for lockbox attribute if lockbox_encrypted_attributes[k] # check if ciphertext attribute nil to avoid loading attribute v = send(k).nil? ? "nil" : "[FILTERED]" k = lockbox_encrypted_attributes[k] elsif values.key?(k) v = respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : values[k].inspect # fix for https://github.com/rails/rails/issues/40725 # TODO only apply to Active Record 6.0 if respond_to?(:inspection_filter, true) && v != "nil" v = inspection_filter.filter_param(k, v) end else next end inspection << "#{k}: #{v}" end "#<#{self.class} #{inspection.join(", ")}>" end if activerecord # TODO wrap in module? def attributes # load attributes # essentially a no-op if already loaded # an exception is thrown if decryption fails self.class.lockbox_attributes.each do |_, lockbox_attribute| # don't try to decrypt if no decryption key given next if lockbox_attribute[:algorithm] == "hybrid" && lockbox_attribute[:decryption_key].nil? # it is possible that the encrypted attribute is not loaded, eg. # if the record was fetched partially (`User.select(:id).first`). # accessing a not loaded attribute raises an `ActiveModel::MissingAttributeError`. send(lockbox_attribute[:attribute]) if has_attribute?(lockbox_attribute[:encrypted_attribute]) end super end # needed for in-place modifications # assigned attributes are encrypted on assignment # and then again here def lockbox_sync_attributes self.class.lockbox_attributes.each do |_, lockbox_attribute| attribute = lockbox_attribute[:attribute] if attribute_changed_in_place?(attribute) || (send("#{attribute}_changed?") && !send("#{lockbox_attribute[:encrypted_attribute]}_changed?")) send("#{attribute}=", send(attribute)) end end end # safety check [:_create_record, :_update_record].each do |method_name| unless private_method_defined?(method_name) || method_defined?(method_name) raise Lockbox::Error, "Expected #{method_name} to be defined. Please report an issue." end end def _create_record(*) lockbox_sync_attributes super end def _update_record(*) lockbox_sync_attributes super end def [](attr_name) send(attr_name) if self.class.lockbox_attributes.any? { |_, la| la[:attribute] == attr_name.to_s } super end def update_columns(attributes) return super unless attributes.is_a?(Hash) # transform keys like Active Record attributes = attributes.transform_keys do |key| n = key.to_s self.class.attribute_aliases[n] || n end lockbox_attributes = self.class.lockbox_attributes.slice(*attributes.keys.map(&:to_sym)) return super unless lockbox_attributes.any? attributes_to_set = {} lockbox_attributes.each do |key, lockbox_attribute| attribute = key.to_s # check read only verify_readonly_attribute(attribute) message = attributes[attribute] attributes.delete(attribute) unless lockbox_attribute[:migrating] encrypted_attribute = lockbox_attribute[:encrypted_attribute] ciphertext = self.class.send("generate_#{encrypted_attribute}", message, context: self) attributes[encrypted_attribute] = ciphertext attributes_to_set[attribute] = message attributes_to_set[lockbox_attribute[:attribute]] = message if lockbox_attribute[:migrating] end result = super(attributes) # same logic as Active Record # (although this happens before saving) attributes_to_set.each do |k, v| if respond_to?(:write_attribute_without_type_cast, true) write_attribute_without_type_cast(k, v) elsif respond_to?(:raw_write_attribute, true) raw_write_attribute(k, v) else @attributes.write_cast_value(k, v) clear_attribute_change(k) end end result end else def reload self.class.lockbox_attributes.each do |_, v| instance_variable_set("@#{v[:attribute]}", nil) end super end end end raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name] raise "Multiple encrypted attributes use the same column: #{encrypted_attribute}" if lockbox_attributes.any? { |_, v| v[:encrypted_attribute] == encrypted_attribute } @lockbox_attributes[original_name] = options if activerecord # preference: # 1. type option # 2. existing virtual attribute # 3. default to string (which can later be overridden) if options[:type] attribute_type = case options[:type] when :json, :hash, :array :string when :integer ActiveModel::Type::Integer.new(limit: 8) else options[:type] end attribute name, attribute_type serialize name, JSON if options[:type] == :json serialize name, Hash if options[:type] == :hash serialize name, Array if options[:type] == :array elsif !attributes_to_define_after_schema_loads.key?(name.to_s) # when migrating it's best to specify the type directly # however, we can try to use the original type if its already defined if attributes_to_define_after_schema_loads.key?(original_name.to_s) attribute name, attributes_to_define_after_schema_loads[original_name.to_s].first elsif options[:migrating] # we use the original attribute for serialization in the encrypt and decrypt methods # so we can use a generic value here attribute name, ActiveRecord::Type::Value.new else attribute name, :string end else # hack for Active Record 6.1 # to set string type after serialize # otherwise, type gets set to ActiveModel::Type::Value # which always returns false for changed_in_place? # earlier versions of Active Record take the previous code path if ActiveRecord::VERSION::STRING.to_f >= 7.0 && attributes_to_define_after_schema_loads[name.to_s].first.is_a?(Proc) attribute_type = attributes_to_define_after_schema_loads[name.to_s].first.call(nil) if attribute_type.is_a?(ActiveRecord::Type::Serialized) && attribute_type.subtype.nil? attribute name, ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, attribute_type.coder) end elsif ActiveRecord::VERSION::STRING.to_f >= 6.1 && attributes_to_define_after_schema_loads[name.to_s].first.is_a?(Proc) attribute_type = attributes_to_define_after_schema_loads[name.to_s].first.call if attribute_type.is_a?(ActiveRecord::Type::Serialized) && attribute_type.subtype.nil? attribute name, ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, attribute_type.coder) end end end define_method("#{name}_was") do send(name) # writes attribute when not already set super() end # restore ciphertext as well define_method("restore_#{name}!") do super() send("restore_#{encrypted_attribute}!") end if ActiveRecord::VERSION::STRING >= "5.1" define_method("#{name}_in_database") do send(name) # writes attribute when not already set super() end end else # keep this module dead simple # Mongoid uses changed_attributes to calculate keys to update # so we shouldn't mess with it m = Module.new do define_method("#{name}=") do |val| instance_variable_set("@#{name}", val) end define_method(name) do instance_variable_get("@#{name}") end end include m alias_method "#{name}_changed?", "#{encrypted_attribute}_changed?" define_method "#{name}_was" do ciphertext = send("#{encrypted_attribute}_was") self.class.send(decrypt_method_name, ciphertext, context: self) end define_method "#{name}_change" do ciphertexts = send("#{encrypted_attribute}_change") ciphertexts.map { |v| self.class.send(decrypt_method_name, v, context: self) } if ciphertexts end define_method "reset_#{name}!" do instance_variable_set("@#{name}", nil) send("reset_#{encrypted_attribute}!") send(name) end define_method "reset_#{name}_to_default!" do instance_variable_set("@#{name}", nil) send("reset_#{encrypted_attribute}_to_default!") send(name) end end define_method("#{name}?") do send("#{encrypted_attribute}?") end define_method("#{name}=") do |message| # decrypt first for dirty tracking # don't raise error if can't decrypt previous # don't try to decrypt if no decryption key given unless options[:algorithm] == "hybrid" && options[:decryption_key].nil? begin send(name) rescue Lockbox::DecryptionError warn "[lockbox] Decrypting previous value failed" end end send("lockbox_direct_#{name}=", message) # warn every time, as this should be addressed # maybe throw an error in the future if !options[:migrating] if activerecord if self.class.columns_hash.key?(name.to_s) warn "[lockbox] WARNING: Unencrypted column with same name: #{name}. Set `ignored_columns` or remove it to protect the data." end else if self.class.fields.key?(name.to_s) warn "[lockbox] WARNING: Unencrypted field with same name: #{name}. Remove it to protect the data." end end end super(message) end # separate method for setting directly # used to skip blind indexes for key rotation define_method("lockbox_direct_#{name}=") do |message| ciphertext = self.class.send(encrypt_method_name, message, context: self) send("#{encrypted_attribute}=", ciphertext) end private :"lockbox_direct_#{name}=" define_method(name) do message = super() # possibly keep track of decrypted attributes directly in the future # Hash serializer returns {} when nil, Array serializer returns [] when nil # check for this explicitly as a layer of safety if message.nil? || ((message == {} || message == []) && activerecord && @attributes[name.to_s].value_before_type_cast.nil?) ciphertext = send(encrypted_attribute) # keep original message for empty hashes and arrays unless ciphertext.nil? message = self.class.send(decrypt_method_name, ciphertext, context: self) end if activerecord # set previous attribute so changes populate correctly # it's fine if this is set on future decryptions (as is the case when message is nil) # as only the first value is loaded into changes @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message) # cache # decrypt method does type casting if respond_to?(:write_attribute_without_type_cast, true) write_attribute_without_type_cast(name.to_s, message) if !@attributes.frozen? elsif respond_to?(:raw_write_attribute, true) raw_write_attribute(name, message) if !@attributes.frozen? else if !@attributes.frozen? @attributes.write_cast_value(name.to_s, message) clear_attribute_change(name) end end else instance_variable_set("@#{name}", message) end end message end # for fixtures define_singleton_method encrypt_method_name do |message, **opts| table = activerecord ? table_name : collection_name.to_s unless message.nil? # TODO use attribute type class in 0.7.0 case options[:type] when :boolean message = ActiveRecord::Type::Boolean.new.serialize(message) message = nil if message == "" # for Active Record < 5.2 message = message ? "t" : "f" unless message.nil? when :date message = ActiveRecord::Type::Date.new.serialize(message) # strftime should be more stable than to_s(:db) message = message.strftime("%Y-%m-%d") unless message.nil? when :datetime message = ActiveRecord::Type::DateTime.new.serialize(message) message = nil unless message.respond_to?(:iso8601) # for Active Record < 5.2 message = message.iso8601(9) unless message.nil? when :time message = ActiveRecord::Type::Time.new.serialize(message) message = nil unless message.respond_to?(:strftime) message = message.strftime("%H:%M:%S.%N") unless message.nil? message when :integer message = ActiveRecord::Type::Integer.new(limit: 8).serialize(message) message = 0 if message.nil? # signed 64-bit integer, big endian message = [message].pack("q>") when :float message = ActiveRecord::Type::Float.new.serialize(message) # double precision, big endian message = [message].pack("G") unless message.nil? when :inet unless message.nil? ip = message.is_a?(IPAddr) ? message : (IPAddr.new(message) rescue nil) # same format as Postgres, with ipv4 padded to 16 bytes # family, netmask, ip # return nil for invalid IP like Active Record message = ip ? [ip.ipv4? ? 0 : 1, ip.prefix, ip.hton].pack("CCa16") : nil end when :string, :binary # do nothing # encrypt will convert to binary else # use original name for serialized attributes type = (try(:attribute_types) || {})[original_name.to_s] message = type.serialize(message) if type end end if message.nil? || (message == "" && !options[:padding]) message else Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message) end end define_singleton_method decrypt_method_name do |ciphertext, **opts| message = if ciphertext.nil? || (ciphertext == "" && !options[:padding]) ciphertext else table = activerecord ? table_name : collection_name.to_s Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).decrypt(ciphertext) end unless message.nil? # TODO use attribute type class in 0.7.0 case options[:type] when :boolean message = message == "t" when :date message = ActiveRecord::Type::Date.new.deserialize(message) when :datetime message = ActiveRecord::Type::DateTime.new.deserialize(message) when :time message = ActiveRecord::Type::Time.new.deserialize(message) when :integer message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first) when :float message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first) when :string message.force_encoding(Encoding::UTF_8) when :binary # do nothing # decrypt returns binary string when :inet family, prefix, addr = message.unpack("CCa16") len = family == 0 ? 4 : 16 message = IPAddr.new_ntoh(addr.first(len)) message.prefix = prefix else # use original name for serialized attributes type = (try(:attribute_types) || {})[original_name.to_s] message = type.deserialize(message) if type message.force_encoding(Encoding::UTF_8) if !type || type.is_a?(ActiveModel::Type::String) end end message end if options[:migrating] # TODO reuse module m = Module.new do define_method "#{original_name}=" do |value| result = super(value) send("#{name}=", send(original_name)) result end unless activerecord define_method "reset_#{original_name}!" do result = super() send("#{name}=", send(original_name)) result end end end prepend m end end end end module Attached def encrypts_attached(*attributes, **options) attributes.each do |name| name = name.to_sym class_eval do @lockbox_attachments ||= {} if @lockbox_attachments.empty? def self.lockbox_attachments parent_attachments = if superclass.respond_to?(:lockbox_attachments) superclass.lockbox_attachments else {} end parent_attachments.merge(@lockbox_attachments || {}) end end raise "Duplicate encrypted attachment: #{name}" if lockbox_attachments[name] @lockbox_attachments[name] = options end end end end end end lockbox-0.6.4/lib/lockbox/railtie.rb0000644000004100000410000000350014042066300017370 0ustar www-datawww-datamodule Lockbox class Railtie < Rails::Railtie initializer "lockbox" do |app| if defined?(Rails.application.credentials) # needs to work when lockbox key has a string value Lockbox.master_key ||= Rails.application.credentials.try(:lockbox).try(:fetch, :master_key, nil) end require "lockbox/carrier_wave_extensions" if defined?(CarrierWave) if defined?(ActiveStorage) require "lockbox/active_storage_extensions" ActiveStorage::Attached.prepend(Lockbox::ActiveStorageExtensions::Attached) if ActiveStorage::VERSION::MAJOR >= 6 ActiveStorage::Attached::Changes::CreateOne.prepend(Lockbox::ActiveStorageExtensions::CreateOne) end ActiveStorage::Attached::One.prepend(Lockbox::ActiveStorageExtensions::AttachedOne) ActiveStorage::Attached::Many.prepend(Lockbox::ActiveStorageExtensions::AttachedMany) # use load hooks when possible if ActiveStorage::VERSION::MAJOR >= 7 ActiveSupport.on_load(:active_storage_attachment) do prepend Lockbox::ActiveStorageExtensions::Attachment end ActiveSupport.on_load(:active_storage_blob) do prepend Lockbox::ActiveStorageExtensions::Blob end elsif ActiveStorage::VERSION::MAJOR >= 6 ActiveSupport.on_load(:active_storage_attachment) do include Lockbox::ActiveStorageExtensions::Attachment end ActiveSupport.on_load(:active_storage_blob) do prepend Lockbox::ActiveStorageExtensions::Blob end else app.config.to_prepare do ActiveStorage::Attachment.include(Lockbox::ActiveStorageExtensions::Attachment) ActiveStorage::Blob.prepend(Lockbox::ActiveStorageExtensions::Blob) end end end end end end lockbox-0.6.4/lib/lockbox/calculations.rb0000644000004100000410000000210614042066300020421 0ustar www-datawww-datamodule Lockbox module Calculations def pluck(*column_names) return super unless model.respond_to?(:lockbox_attributes) lockbox_columns = column_names.map.with_index { |c, i| [model.lockbox_attributes[c.to_sym], i] }.select { |la, _i| la && !la[:migrating] } return super unless lockbox_columns.any? # replace column with ciphertext column lockbox_columns.each do |la, i| column_names[i] = la[:encrypted_attribute] end # pluck result = super(*column_names) # decrypt result # handle pluck to single columns and multiple # # we can't pass context to decrypt method # so this won't work if any options are a symbol or proc if column_names.size == 1 la = lockbox_columns.first.first result.map! { |v| model.send("decrypt_#{la[:encrypted_attribute]}", v) } else lockbox_columns.each do |la, i| result.each do |v| v[i] = model.send("decrypt_#{la[:encrypted_attribute]}", v[i]) end end end result end end end lockbox-0.6.4/lib/lockbox/utils.rb0000644000004100000410000001114614042066300017104 0ustar www-datawww-datamodule Lockbox class Utils def self.build_box(context, options, table, attribute) # dup options (with except) since keys are sometimes changed or deleted options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type) options[:encode] = false unless options.key?(:encode) options.each do |k, v| if v.respond_to?(:call) # context not present for pluck # still possible to use if not dependent on context options[k] = context ? context.instance_exec(&v) : v.call elsif v.is_a?(Symbol) # context not present for pluck raise Error, "Not available since :#{k} depends on record" unless context options[k] = context.send(v) end end unless options[:key] || options[:encryption_key] || options[:decryption_key] options[:key] = Lockbox.attribute_key( table: options.delete(:key_table) || table, attribute: options.delete(:key_attribute) || attribute, master_key: options.delete(:master_key), encode: false ) end if options[:previous_versions].is_a?(Array) # dup previous versions array (with map) since elements are updated # dup each version (with dup) since keys are sometimes deleted options[:previous_versions] = options[:previous_versions].map(&:dup) options[:previous_versions].each_with_index do |version, i| if !(version[:key] || version[:encryption_key] || version[:decryption_key]) && (version[:master_key] || version[:key_table] || version[:key_attribute]) # could also use key_table and key_attribute from options # when specified, but keep simple for now # also, this change isn't backward compatible key = Lockbox.attribute_key( table: version.delete(:key_table) || table, attribute: version.delete(:key_attribute) || attribute, master_key: version.delete(:master_key), encode: false ) options[:previous_versions][i] = version.merge(key: key) end end end Lockbox.new(**options) end def self.encrypted_options(record, name) record.class.respond_to?(:lockbox_attachments) ? record.class.lockbox_attachments[name.to_sym] : nil end def self.decode_key(key, size: 32, name: "Key") if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{#{size * 2}}\z/i key = [key].pack("H*") end raise Lockbox::Error, "#{name} must be #{size} bytes (#{size * 2} hex digits)" if key.bytesize != size raise Lockbox::Error, "#{name} must use binary encoding" if key.encoding != Encoding::BINARY key end def self.encrypted?(record, name) !encrypted_options(record, name).nil? end def self.encrypt_attachable(record, name, attachable) io = nil ActiveSupport::Notifications.instrument("encrypt_file.lockbox", {name: name}) do options = encrypted_options(record, name) box = build_box(record, options, record.class.table_name, name) case attachable when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile io = attachable attachable = { io: box.encrypt_io(io), filename: attachable.original_filename, content_type: attachable.content_type } when Hash io = attachable[:io] attachable = attachable.dup attachable[:io] = box.encrypt_io(io) else raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}" end # don't analyze encrypted data metadata = {"analyzed" => true, "encrypted" => true} attachable[:metadata] = (attachable[:metadata] || {}).merge(metadata) end # set content type based on unencrypted data # keep synced with ActiveStorage::Blob#extract_content_type attachable[:io].extracted_content_type = Marcel::MimeType.for(io, name: attachable[:filename].to_s, declared_type: attachable[:content_type]) attachable end def self.decrypt_result(record, name, options, result) ActiveSupport::Notifications.instrument("decrypt_file.lockbox", {name: name}) do Utils.build_box(record, options, record.class.table_name, name).decrypt(result) end end def self.rebuild_attachable(attachment) { io: StringIO.new(attachment.download), filename: attachment.filename, content_type: attachment.content_type } end end end lockbox-0.6.4/lib/lockbox/io.rb0000644000004100000410000000025014042066300016345 0ustar www-datawww-datamodule Lockbox class IO < StringIO attr_accessor :original_filename, :content_type # private: do not use attr_accessor :extracted_content_type end end lockbox-0.6.4/lib/lockbox/aes_gcm.rb0000644000004100000410000000437714042066300017352 0ustar www-datawww-datamodule Lockbox class AES_GCM def initialize(key) raise ArgumentError, "Key must be 32 bytes" unless key && key.bytesize == 32 raise ArgumentError, "Key must be binary" unless key.encoding == Encoding::BINARY @key = key end def encrypt(nonce, message, associated_data) cipher = OpenSSL::Cipher.new("aes-256-gcm") # do not change order of operations cipher.encrypt cipher.key = @key cipher.iv = nonce # From Ruby 2.5.3 OpenSSL::Cipher docs: # If no associated data shall be used, this method must still be called with a value of "" # In encryption mode, it must be set after calling #encrypt and setting #key= and #iv= cipher.auth_data = associated_data || "" ciphertext = String.new ciphertext << cipher.update(message) unless message.empty? ciphertext << cipher.final ciphertext << cipher.auth_tag ciphertext end def decrypt(nonce, ciphertext, associated_data) auth_tag, ciphertext = extract_auth_tag(ciphertext.to_s) fail_decryption if nonce.to_s.bytesize != nonce_bytes fail_decryption if auth_tag.to_s.bytesize != auth_tag_bytes cipher = OpenSSL::Cipher.new("aes-256-gcm") # do not change order of operations cipher.decrypt cipher.key = @key cipher.iv = nonce cipher.auth_tag = auth_tag # From Ruby 2.5.3 OpenSSL::Cipher docs: # If no associated data shall be used, this method must still be called with a value of "" # When decrypting, set it only after calling #decrypt, #key=, #iv= and #auth_tag= first. cipher.auth_data = associated_data || "" begin message = String.new message << cipher.update(ciphertext) unless ciphertext.to_s.empty? message << cipher.final message rescue OpenSSL::Cipher::CipherError fail_decryption end end def nonce_bytes 12 end # protect key def inspect to_s end private def auth_tag_bytes 16 end def extract_auth_tag(bytes) auth_tag = bytes.slice(-auth_tag_bytes..-1) [auth_tag, bytes.slice(0, bytes.bytesize - auth_tag_bytes)] end def fail_decryption raise DecryptionError, "Decryption failed" end end end lockbox-0.6.4/lib/lockbox/encryptor.rb0000644000004100000410000000554714042066300020001 0ustar www-datawww-datamodule Lockbox class Encryptor def initialize(**options) options = Lockbox.default_options.merge(options) @encode = options.delete(:encode) # option may be renamed to binary: true # warn "[lockbox] Lockbox 1.0 will default to encode: true. Pass encode: false to keep the current behavior." if @encode.nil? previous_versions = options.delete(:previous_versions) @boxes = [Box.new(**options)] + Array(previous_versions).map { |v| Box.new(key: options[:key], **v) } end def encrypt(message, **options) message = check_string(message) ciphertext = @boxes.first.encrypt(message, **options) ciphertext = Base64.strict_encode64(ciphertext) if @encode ciphertext end def decrypt(ciphertext, **options) ciphertext = Base64.decode64(ciphertext) if @encode ciphertext = check_string(ciphertext) # ensure binary if ciphertext.encoding != Encoding::BINARY # dup to prevent mutation ciphertext = ciphertext.dup.force_encoding(Encoding::BINARY) end @boxes.each_with_index do |box, i| begin return box.decrypt(ciphertext, **options) rescue => e # returning DecryptionError instead of PaddingError # is for end-user convenience, not for security error_classes = [DecryptionError, PaddingError] error_classes << RbNaCl::LengthError if defined?(RbNaCl::LengthError) error_classes << RbNaCl::CryptoError if defined?(RbNaCl::CryptoError) if error_classes.any? { |ec| e.is_a?(ec) } raise DecryptionError, "Decryption failed" if i == @boxes.size - 1 else raise e end end end end def encrypt_io(io, **options) new_io = Lockbox::IO.new(encrypt(io.read, **options)) copy_metadata(io, new_io) new_io end def decrypt_io(io, **options) new_io = Lockbox::IO.new(decrypt(io.read, **options)) copy_metadata(io, new_io) new_io end def decrypt_str(ciphertext, **options) message = decrypt(ciphertext, **options) message.force_encoding(Encoding::UTF_8) end private def check_string(str) str = str.read if str.respond_to?(:read) # Ruby uses "no implicit conversion of Object into String" raise TypeError, "can't convert #{str.class.name} to String" unless str.respond_to?(:to_str) str.to_str end def copy_metadata(source, target) target.original_filename = if source.respond_to?(:original_filename) source.original_filename elsif source.respond_to?(:path) File.basename(source.path) end target.content_type = source.content_type if source.respond_to?(:content_type) target.set_encoding(source.external_encoding) if source.respond_to?(:external_encoding) end end end lockbox-0.6.4/SECURITY.md0000644000004100000410000000016014042066300014773 0ustar www-datawww-data# Security Policy For security issues, send an email to the address on [this page](https://github.com/ankane). lockbox-0.6.4/LICENSE.txt0000644000004100000410000000207314042066300015032 0ustar www-datawww-dataThe MIT License (MIT) Copyright (c) 2018-2021 Andrew Kane Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.