pax_global_header00006660000000000000000000000064151642761720014525gustar00rootroot0000000000000052 comment=c8a7a5f477ed4ed4e6aca8d21de13052b6504750 ankane-lockbox-c8a7a5f/000077500000000000000000000000001516427617200151215ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/.github/000077500000000000000000000000001516427617200164615ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/.github/workflows/000077500000000000000000000000001516427617200205165ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/.github/workflows/build.yml000066400000000000000000000026521516427617200223450ustar00rootroot00000000000000name: build on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - ruby: "4.0" gemfile: Gemfile - ruby: 3.4 gemfile: gemfiles/rails80.gemfile - ruby: 3.3 gemfile: gemfiles/rails72.gemfile - ruby: 3.4 gemfile: gemfiles/mongoid9.gemfile mongodb: true - ruby: 3.3 gemfile: gemfiles/mongoid8.gemfile mongodb: true env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - if: ${{ matrix.mongodb }} uses: ankane/setup-mongodb@v1 - run: sudo apt-get update && sudo apt-get install libsodium23 libvips42 poppler-utils - run: bundle exec rake test - if : ${{ !matrix.mongodb }} uses: ankane/setup-postgres@v1 with: database: lockbox_test - if : ${{ !matrix.mongodb }} run: ADAPTER=postgresql bundle exec rake test - if : ${{ !matrix.mongodb }} uses: ankane/setup-mysql@v1 with: database: lockbox_test - if : ${{ !matrix.mongodb }} run: ADAPTER=mysql2 bundle exec rake test - if : ${{ !matrix.mongodb }} run: ADAPTER=trilogy bundle exec rake test ankane-lockbox-c8a7a5f/.gitignore000066400000000000000000000001401516427617200171040ustar00rootroot00000000000000/.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ *.log *.sqlite* *.lock ankane-lockbox-c8a7a5f/CHANGELOG.md000066400000000000000000000164541516427617200167440ustar00rootroot00000000000000## 2.2.0 (2026-04-04) - Dropped support for Active Record < 7.2 and Ruby < 3.3 ## 2.1.0 (2025-10-15) - Added warning for `download_chunk` method - Fixed error for `download` method with block - Dropped support for Active Record < 7.1 and Ruby < 3.2 ## 2.0.1 (2024-12-29) - Added support for Ruby 3.4 ## 2.0.0 (2024-10-26) - Improved `attributes`, `attribute_names`, and `has_attribute?` when ciphertext attributes not loaded - Removed deprecated `lockbox_encrypts` (use `has_encrypted` instead) - Dropped support for Active Record < 7 and Ruby < 3.1 - Dropped support for Mongoid < 8 ## 1.4.1 (2024-09-09) - Fixed error message for previews for Active Storage 7.1.4 ## 1.4.0 (2024-08-09) - Added support for Active Record 7.2 - Added support for Mongoid 9 - Fixed error when `decryption_key` option is a proc or symbol and returns `nil` ## 1.3.3 (2024-02-07) - Added warning for encrypting store attributes ## 1.3.2 (2024-01-10) - Fixed issue with serialized attributes ## 1.3.1 (2024-01-06) - Fixed error with `array` and `hash` types and no default column serializer with Rails 7.1 - Fixed Action Text deserialization with Rails 7.1 ## 1.3.0 (2023-07-02) - Added support for CarrierWave 3 ## 1.2.0 (2023-03-20) - Made it easier to rotate master key - Added `associated_data` option for database fields and files - Added `decimal` type - Added `encode_attributes` option - Fixed deprecation warnings with Rails 7.1 ## 1.1.2 (2023-02-01) - Fixed error when migrating to `array`, `hash`, and `json` types ## 1.1.1 (2022-12-08) - Fixed error when `StringIO` not loaded ## 1.1.0 (2022-10-09) - Added support for `insert`, `insert_all`, `insert_all!`, `upsert`, and `upsert_all` ## 1.0.0 (2022-06-11) - Deprecated `encrypts` in favor of `has_encrypted` to avoid conflicting with Active Record encryption - Deprecated `lockbox_encrypts` in favor of `has_encrypted` - Fixed error with `pluck` - Restored warning for attributes with `default` option - Dropped support for Active Record < 5.2 and Ruby < 2.6 ## 0.6.8 (2022-01-25) - Fixed issue with `encrypts` loading model schema early - Removed warning for attributes with `default` option ## 0.6.7 (2022-01-25) - Added warning for attributes with `default` option - Removed warning for Active Record 5.0 (still supported) ## 0.6.6 (2021-09-27) - Fixed `attribute?` method for `boolean` and `integer` types ## 0.6.5 (2021-07-07) - Fixed issue with `pluck` extension not loading in some cases ## 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 - Added warning for unsupported versions of Active Record - 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 ankane-lockbox-c8a7a5f/Gemfile000066400000000000000000000005301516427617200164120ustar00rootroot00000000000000source "https://rubygems.org" gemspec gem "rake" gem "minitest" gem "rails", "~> 8.1.0" gem "carrierwave", "~> 3" gem "combustion", ">= 1.3" gem "rbnacl", ">= 6" gem "shrine" gem "base64" gem "sqlite3", platform: :ruby gem "pg", platform: :ruby gem "mysql2", platform: :ruby gem "trilogy", platform: :ruby gem "sqlite3-ffi", platform: :jruby ankane-lockbox-c8a7a5f/LICENSE.txt000066400000000000000000000020731516427617200167460ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2018-2026 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. ankane-lockbox-c8a7a5f/README.md000066400000000000000000000550001516427617200164000ustar00rootroot00000000000000# 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/actions/workflows/build.yml/badge.svg)](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 `) ```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[8.1] def change add_column :users, :email_ciphertext, :text end end ``` Add to your model: ```ruby class User < ApplicationRecord has_encrypted :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 has_encrypted :email, :phone, :city end ``` #### Types Fields are strings by default. Specify the type of a field with: ```ruby 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. ```ruby 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](https://api.rubyonrails.org/classes/ActiveRecord/Store.html), encrypt the column rather than individual accessors. For [StoreModel](https://github.com/DmitryTsepelev/store_model), use: ```ruby 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](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 has_encrypted :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 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. ```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[8.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 has_encrypted :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 ``` 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: ```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[8.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 ``` 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: ```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 ``` Use `filename` to specify a filename or `disposition: "inline"` to show inline. #### 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. Create `config/initializers/lockbox.rb` with: ```ruby Lockbox.default_options[:previous_versions] = [{master_key: previous_key}] ``` To rotate existing Active Record & Mongoid records, use: ```ruby Lockbox.rotate(User, attributes: [:email]) ``` To rotate existing Action Text records, use: ```ruby Lockbox.rotate(ActionText::RichText, attributes: [:body]) ``` To rotate existing Active Storage files, use: ```ruby User.with_attached_license.find_each do |user| user.license.rotate_encryption! end ``` To rotate existing CarrierWave files, use: ```ruby 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. ```ruby class User < ApplicationRecord has_encrypted :email, previous_versions: [{master_key: previous_key}] end ``` ### Local Files & Strings To rotate 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). It comes preinstalled on [Heroku](https://devcenter.heroku.com/articles/stack-packages). For Homebrew, use: ```sh brew install libsodium ``` And for Ubuntu, use: ```sh sudo apt-get install libsodium23 ``` Then add to your Gemfile: ```ruby gem "rbnacl" ``` And add to your model: ```ruby class User < ApplicationRecord has_encrypted :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. ## 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 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](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 has_encrypted :email, key_table: "original_table" end ``` To rename an encrypted column itself, use: ```ruby 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: ```ruby class User < ApplicationRecord has_encrypted :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"] end ``` Or a proc: ```ruby class User < ApplicationRecord has_encrypted :email, key: -> { code } end ``` ### Per Record To use a different key for each record, use a symbol: ```ruby class User < ApplicationRecord has_encrypted :email, key: :some_method end ``` Or a proc: ```ruby 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](https://github.com/ankane/kms_encrypted). For Active Record and Mongoid, use: ```ruby class User < ApplicationRecord has_encrypted :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 ``` You can also use it with database fields and files. ```ruby class User < ApplicationRecord has_encrypted :email, associated_data: -> { code } end ``` ## Binary Columns You can use `binary` columns for the ciphertext instead of `text` columns. ```ruby class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.1] def change add_column :users, :email_ciphertext, :binary end end ``` Disable Base64 encoding to save space. ```ruby class User < ApplicationRecord has_encrypted :email, encode: false end ``` or set it globally: ```ruby 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](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[8.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: ```ruby class User < ApplicationRecord has_encrypted :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 has_encrypted :name, :email end ``` Then remove the previous gem from your Gemfile and drop its columns. ```ruby class RemovePreviousEncryptedColumns < ActiveRecord::Migration[8.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 ``` ## 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). ankane-lockbox-c8a7a5f/Rakefile000066400000000000000000000011731516427617200165700ustar00rootroot00000000000000require "bundler/gem_tasks" require "rake/testtask" Rake::TestTask.new do |t| t.test_files = FileList["test/**/*_test.rb"] t.warning = false # for shrine (fixed but not released) end task default: :test task :benchmark do require "benchmark/ips" require "lockbox" require "rbnacl" key = Lockbox.generate_key value = "secret" * 5 aes_gcm = Lockbox.new(key: key, algorithm: "aes-gcm") xsalsa20 = Lockbox.new(key: key, algorithm: "xsalsa20") Benchmark.ips do |x| x.report("aes-gcm") { aes_gcm.decrypt(aes_gcm.encrypt(value)) } x.report("xsalsa20") { xsalsa20.decrypt(xsalsa20.encrypt(value)) } end end ankane-lockbox-c8a7a5f/SECURITY.md000066400000000000000000000001601516427617200167070ustar00rootroot00000000000000# Security Policy For security issues, send an email to the address on [this page](https://github.com/ankane). ankane-lockbox-c8a7a5f/docs/000077500000000000000000000000001516427617200160515ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/docs/Compatibility.md000066400000000000000000000103711516427617200212060ustar00rootroot00000000000000# Compatibility Here’s how to decrypt in other languages. - [Node.js](#node-js) - [Python](#python) - [Rust](#rust) - [Elixir](#elixir) - [PHP](#php) - [Java](#java) Use the [attribute key](https://github.com/ankane/lockbox?tab=readme-ov-file#master-key), not the master key. For files, skip Base64 decoding the ciphertext. Pull requests are welcome for other languages. ## Node.js ```js const crypto = require('crypto') let key = '61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb' let ciphertext = 'Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg==' key = Buffer.from(key, 'hex') ciphertext = Buffer.from(ciphertext, 'base64') // skip for files let nonce = ciphertext.slice(0, 12) let auth_tag = ciphertext.slice(-16) ciphertext = ciphertext.slice(12, -16) let aesgcm = crypto.createDecipheriv('aes-256-gcm', key, nonce) aesgcm.setAuthTag(auth_tag) let plaintext = aesgcm.update(ciphertext) + aesgcm.final() console.log(plaintext) ``` ## Python Install the [cryptography](https://cryptography.io/en/latest/) package and use: ```py from cryptography.hazmat.primitives.ciphers.aead import AESGCM from base64 import b64decode key = '61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb' ciphertext = 'Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg==' key = bytes.fromhex(key) ciphertext = b64decode(ciphertext) # skip for files aesgcm = AESGCM(key) plaintext = aesgcm.decrypt(ciphertext[:12], ciphertext[12:], b'') print(plaintext) ``` ## Rust Add crates: ```toml [dependencies] aes-gcm = "0.10.3" base64 = "0.22.1" hex = "0.4.3" ``` And use: ```rust use aes_gcm::aead::{generic_array::GenericArray, Aead}; use aes_gcm::{Aes256Gcm, Key, KeyInit}; use base64::prelude::*; fn main() { let key = hex::decode("61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb").expect("decode failure!"); let ciphertext = BASE64_STANDARD.decode("Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg==").expect("decode failure!"); let key = Key::::from_slice(&key); let aead = Aes256Gcm::new(key); let nonce = GenericArray::from_slice(&ciphertext[..12]); let plaintext_bytes = aead.decrypt(nonce, &ciphertext[12..]).expect("decryption failure!"); let plaintext = String::from_utf8(plaintext_bytes).expect("utf8 failure!"); println!("{:?}", plaintext); } ``` Check out the [aes-gcm docs](https://docs.rs/aes-gcm/) for more on security and performance. ## Elixir ```ex {:ok, key} = Base.decode16("61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb", case: :lower) {:ok, ciphertext} = Base.decode64("Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg==") data_size = byte_size(ciphertext) - 28 <> = ciphertext plaintext = :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, data, "", tag, false) IO.puts(plaintext) ``` ## PHP ```php $key = "61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb"; $ciphertext = "Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg=="; $key = hex2bin($key); $ciphertext = base64_decode($ciphertext, true); $nonce = substr($ciphertext, 0, 12); $tag = substr($ciphertext, -16); $ciphertext = substr($ciphertext, 12, -16); $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag); echo $plaintext . "\n"; ``` ## Java ```java import java.util.Base64; import java.util.HexFormat; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; public class Example { public static void main(String[] args) throws Exception { String key = "61e6ba4a3a2498e3a8fdcd047eff0cd9864016f2c83c34599a3257a57ce6f7fb"; String ciphertext = "Uv/+Sgar0kM216AvVlBH5Gt8vIwtQGfPysl539WY2DER62AoJg=="; byte[] keyBytes = HexFormat.of().parseHex(key); byte[] ciphertextBytes = Base64.getDecoder().decode(ciphertext); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, ciphertextBytes, 0, 12)); byte[] plaintextBytes = cipher.doFinal(ciphertextBytes, 12, ciphertextBytes.length - 12); String plaintext = new String(plaintextBytes); System.out.println(plaintext); } } ``` ankane-lockbox-c8a7a5f/gemfiles/000077500000000000000000000000001516427617200167145ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/gemfiles/mongoid8.gemfile000066400000000000000000000003231516427617200217700ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "rake" gem "minitest" gem "mongoid", "~> 8" gem "rails" gem "carrierwave" gem "combustion", ">= 1.3" gem "rbnacl", ">= 6" gem "shrine" gem "shrine-mongoid" ankane-lockbox-c8a7a5f/gemfiles/mongoid9.gemfile000066400000000000000000000003231516427617200217710ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "rake" gem "minitest" gem "mongoid", "~> 9" gem "rails" gem "carrierwave" gem "combustion", ">= 1.3" gem "rbnacl", ">= 6" gem "shrine" gem "shrine-mongoid" ankane-lockbox-c8a7a5f/gemfiles/rails72.gemfile000066400000000000000000000003561516427617200215350ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "rake" gem "minitest" gem "rails", "~> 7.2.0" gem "carrierwave", "~> 3" gem "combustion", ">= 1.3" gem "rbnacl", ">= 6" gem "sqlite3" gem "pg" gem "mysql2" gem "trilogy" gem "shrine" ankane-lockbox-c8a7a5f/gemfiles/rails80.gemfile000066400000000000000000000003561516427617200215340ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "rake" gem "minitest" gem "rails", "~> 8.0.0" gem "carrierwave", "~> 3" gem "combustion", ">= 1.3" gem "rbnacl", ">= 6" gem "sqlite3" gem "pg" gem "mysql2" gem "trilogy" gem "shrine" ankane-lockbox-c8a7a5f/lib/000077500000000000000000000000001516427617200156675ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/lib/generators/000077500000000000000000000000001516427617200200405ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/lib/generators/lockbox/000077500000000000000000000000001516427617200215015ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/lib/generators/lockbox/audits_generator.rb000066400000000000000000000017031516427617200253660ustar00rootroot00000000000000require "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 ActiveRecord::Base.connection_db_config.adapter.to_s end end end end ankane-lockbox-c8a7a5f/lib/generators/lockbox/templates/000077500000000000000000000000001516427617200234775ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/lib/generators/lockbox/templates/migration.rb.tt000066400000000000000000000005371516427617200264500ustar00rootroot00000000000000class <%= 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 ankane-lockbox-c8a7a5f/lib/generators/lockbox/templates/model.rb.tt000066400000000000000000000002721516427617200255530ustar00rootroot00000000000000class LockboxAudit < ApplicationRecord belongs_to :subject, polymorphic: true belongs_to :viewer, polymorphic: true<% if data_type == "text" %> serialize :data, JSON<% end %> end ankane-lockbox-c8a7a5f/lib/lockbox.rb000066400000000000000000000075671516427617200176740ustar00rootroot00000000000000# stdlib require "openssl" require "securerandom" require "stringio" # modules require_relative "lockbox/aes_gcm" require_relative "lockbox/box" require_relative "lockbox/calculations" require_relative "lockbox/encryptor" require_relative "lockbox/key_generator" require_relative "lockbox/io" require_relative "lockbox/migrator" require_relative "lockbox/model" require_relative "lockbox/padding" require_relative "lockbox/utils" require_relative "lockbox/version" 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, :encode_attributes attr_writer :master_key end self.default_options = {} self.encode_attributes = true 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.unpack1("H*") 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.has_encrypted :body, **options end end end # integrations require_relative "lockbox/carrier_wave_extensions" if defined?(CarrierWave) require_relative "lockbox/railtie" if defined?(Rails) if defined?(ActiveSupport::LogSubscriber) require_relative "lockbox/log_subscriber" Lockbox::LogSubscriber.attach_to :lockbox end if defined?(ActiveSupport.on_load) ActiveSupport.on_load(:active_record) do ar_version = ActiveRecord::VERSION::STRING.to_f if ar_version < 7.2 if ar_version >= 7.1 raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2.2" elsif ar_version >= 7.0 raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2.1" elsif ar_version >= 5.2 raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2" elsif ar_version >= 5 raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 0.7" else raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} not supported" end end extend Lockbox::Model extend Lockbox::Model::Attached ActiveRecord::Relation.prepend Lockbox::Calculations end ActiveSupport.on_load(:mongoid) do mongoid_version = Mongoid::VERSION.to_i if mongoid_version < 8 if mongoid_version >= 6 raise Lockbox::Error, "Mongoid #{Mongoid::VERSION} requires Lockbox < 2" else raise Lockbox::Error, "Mongoid #{Mongoid::VERSION} not supported" end end Mongoid::Document::ClassMethods.include(Lockbox::Model) end end ankane-lockbox-c8a7a5f/lib/lockbox/000077500000000000000000000000001516427617200173305ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/lib/lockbox/active_storage_extensions.rb000066400000000000000000000112371516427617200251370ustar00rootroot00000000000000# 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 def rotate_encryption! raise "Not encrypted" unless encrypted? attach(Utils.rebuild_attachable(self)) if attached? true end end module AttachedMany 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(&nil) 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 if block_given? io = StringIO.new(result) chunk_size = 5.megabytes while (chunk = io.read(chunk_size)) yield chunk end else result end end def download_chunk(...) # TODO raise error in 3.0 warn "[lockbox] WARNING: download_chunk not supported for encrypted files" if Utils.encrypted_options(record, name) super end def variant(...) raise Lockbox::Error, "Variant not supported for encrypted files" if Utils.encrypted_options(record, name) super end def preview(...) raise Lockbox::Error, "Preview not supported for encrypted files" if Utils.encrypted_options(record, name) super end 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 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 ankane-lockbox-c8a7a5f/lib/lockbox/aes_gcm.rb000066400000000000000000000043771516427617200212660ustar00rootroot00000000000000module 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 ankane-lockbox-c8a7a5f/lib/lockbox/box.rb000066400000000000000000000074601516427617200204540ustar00rootroot00000000000000module Lockbox class Box NOT_SET = Object.new def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false, associated_data: nil) 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 @associated_data = associated_data end def encrypt(message, associated_data: NOT_SET) associated_data = @associated_data if associated_data == NOT_SET 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: NOT_SET) associated_data = @associated_data if associated_data == NOT_SET 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 ankane-lockbox-c8a7a5f/lib/lockbox/calculations.rb000066400000000000000000000022231516427617200223350ustar00rootroot00000000000000module Lockbox module Calculations def pluck(*column_names) return super unless model.respond_to?(:lockbox_attributes) lockbox_columns = column_names.map.with_index do |c, i| next unless c.respond_to?(:to_sym) [model.lockbox_attributes[c.to_sym], i] end.select do |la, _i| la && !la[:migrating] end 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 ankane-lockbox-c8a7a5f/lib/lockbox/carrier_wave_extensions.rb000066400000000000000000000071761516427617200246200ustar00rootroot00000000000000module 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.delete_suffix("Uploader").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 > 3 raise Lockbox::Error, "CarrierWave #{CarrierWave::VERSION} not supported in this version of Lockbox" elsif CarrierWave::VERSION.to_i < 1 raise Lockbox::Error, "CarrierWave #{CarrierWave::VERSION} not supported" end CarrierWave::Uploader::Base.extend(Lockbox::CarrierWaveExtensions) ankane-lockbox-c8a7a5f/lib/lockbox/encryptor.rb000066400000000000000000000055731516427617200217140ustar00rootroot00000000000000module 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).reject { |v| v.key?(:master_key) }.map { |v| Box.new(key: options[:key], **v) } end def encrypt(message, **options) message = check_string(message) ciphertext = @boxes.first.encrypt(message, **options) ciphertext = [ciphertext].pack("m0") if @encode ciphertext end def decrypt(ciphertext, **options) ciphertext = ciphertext.unpack1("m") 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 ankane-lockbox-c8a7a5f/lib/lockbox/io.rb000066400000000000000000000002501516427617200202610ustar00rootroot00000000000000module Lockbox class IO < StringIO attr_accessor :original_filename, :content_type # private: do not use attr_accessor :extracted_content_type end end ankane-lockbox-c8a7a5f/lib/lockbox/key_generator.rb000066400000000000000000000023321516427617200225130ustar00rootroot00000000000000module 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 ankane-lockbox-c8a7a5f/lib/lockbox/log_subscriber.rb000066400000000000000000000010571516427617200226640ustar00rootroot00000000000000module 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, bold: 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, bold: true)} Decrypted #{payload[:name]}" end end end ankane-lockbox-c8a7a5f/lib/lockbox/migrator.rb000066400000000000000000000141511516427617200215030ustar00rootroot00000000000000module Lockbox class Migrator def initialize(relation, batch_size:) @relation = relation @transaction = @relation.respond_to?(:transaction) && !mongoid_relation?(base_relation) @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 ankane-lockbox-c8a7a5f/lib/lockbox/model.rb000066400000000000000000000703321516427617200207620ustar00rootroot00000000000000module Lockbox module Model def has_encrypted(*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 # when BigDecimal # options[:type] = :decimal # end custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize) valid_types = [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :decimal, :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] = Lockbox.encode_attributes 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 # 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 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| # 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`. if has_attribute?(lockbox_attribute[:encrypted_attribute]) begin send(lockbox_attribute[:attribute]) rescue ArgumentError => e raise e if e.message != "No decryption key set" end end end # remove attributes that do not have a ciphertext attribute attributes = super self.class.lockbox_attributes.each do |k, lockbox_attribute| if !attributes.include?(lockbox_attribute[:encrypted_attribute].to_s) attributes.delete(k.to_s) attributes.delete(lockbox_attribute[:attribute]) end end attributes end # remove attribute names that do not have a ciphertext attribute def attribute_names # hash preserves key order names_set = super.to_h { |v| [v, true] } self.class.lockbox_attributes.each do |k, lockbox_attribute| if !names_set.include?(lockbox_attribute[:encrypted_attribute].to_s) names_set.delete(k.to_s) names_set.delete(lockbox_attribute[:attribute]) end end names_set.keys end # check the ciphertext attribute for encrypted attributes def has_attribute?(attr_name) attr_name = attr_name.to_s _, lockbox_attribute = self.class.lockbox_attributes.find { |_, la| la[:attribute] == attr_name } if lockbox_attribute super(lockbox_attribute[:encrypted_attribute]) else super end 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 def self.insert(attributes, **options) super(lockbox_map_record_attributes(attributes), **options) end def self.insert!(attributes, **options) super(lockbox_map_record_attributes(attributes), **options) end def self.upsert(attributes, **options) super(lockbox_map_record_attributes(attributes, check_readonly: true), **options) end def self.insert_all(attributes, **options) super(lockbox_map_attributes(attributes), **options) end def self.insert_all!(attributes, **options) super(lockbox_map_attributes(attributes), **options) end def self.upsert_all(attributes, **options) super(lockbox_map_attributes(attributes, check_readonly: true), **options) end # private # does not try to handle :returning option for simplicity def self.lockbox_map_attributes(records, check_readonly: false) return records unless records.is_a?(Array) records.map do |attributes| lockbox_map_record_attributes(attributes, check_readonly: false) end end # private def self.lockbox_map_record_attributes(attributes, check_readonly: false) return attributes unless attributes.is_a?(Hash) # transform keys like Active Record attributes = attributes.transform_keys do |key| n = key.to_s attribute_aliases[n] || n end lockbox_attributes = self.lockbox_attributes.slice(*attributes.keys.map(&:to_sym)) lockbox_attributes.each do |key, lockbox_attribute| attribute = key.to_s # check read only # users should mark both plaintext and ciphertext columns if check_readonly && readonly_attributes.include?(attribute) && !readonly_attributes.include?(lockbox_attribute[:encrypted_attribute].to_s) warn "[lockbox] WARNING: Mark attribute as readonly: #{lockbox_attribute[:encrypted_attribute]}" end message = attributes[attribute] attributes.delete(attribute) unless lockbox_attribute[:migrating] encrypted_attribute = lockbox_attribute[:encrypted_attribute] ciphertext = send("generate_#{encrypted_attribute}", message) attributes[encrypted_attribute] = ciphertext end attributes 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 # warn on store attributes if stored_attributes.any? { |k, v| v.include?(name) } warn "[lockbox] WARNING: encrypting store accessors is not supported. Encrypt the column instead." end # warn on default attributes # TODO improve if pending_attribute_modifications.any? { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingDefault) && v.name == name.to_s } warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead." end # 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 case options[:type] when :json serialize name, coder: JSON when :hash serialize name, type: Hash, coder: default_column_serializer || YAML when :array serialize name, type: Array, coder: default_column_serializer || YAML end else decorate_attributes([name]) do |attr_name, cast_type| if cast_type.instance_of?(ActiveRecord::Type::Value) original_type = pending_attribute_modifications.find { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingType) && v.name == original_name.to_s && !v.type.nil? }&.type if original_type original_type elsif options[:migrating] cast_type else ActiveRecord::Type::String.new end elsif cast_type.is_a?(ActiveRecord::Type::Serialized) && cast_type.subtype.instance_of?(ActiveModel::Type::Value) # hack to set string type after serialize # otherwise, type gets set to ActiveModel::Type::Value # which always returns false for changed_in_place? ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, cast_type.coder) else cast_type 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 define_method("#{name}_in_database") do send(name) # writes attribute when not already set super() end define_method("#{name}?") do # uses public_send, so we don't need to preload attribute query_attribute(name) 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 define_method("#{name}?") do send("#{encrypted_attribute}?") end 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 begin send(name) rescue Lockbox::DecryptionError warn "[lockbox] Decrypting previous value failed" rescue ArgumentError => e raise e if e.message != "No decryption key set" 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 # ensure same object is returned as next call message = super() 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? case options[:type] when :boolean message = ActiveRecord::Type::Boolean.new.serialize(message) 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 = 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 :decimal message = ActiveRecord::Type::Decimal.new.serialize(message) # Postgres stores 4 decimal digits in 2 bytes # plus 3 to 8 bytes of overhead # but use string for simplicity message = message.to_s("F") 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 if no type specified type = (try(:attribute_types) || {})[(options[:type] ? name : 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? 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.unpack1("q>")) when :float message = ActiveRecord::Type::Float.new.deserialize(message.unpack1("G")) when :decimal message = ActiveRecord::Type::Decimal.new.deserialize(message) 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 if no type specified type = (try(:attribute_types) || {})[(options[:type] ? name : original_name).to_s] # for Action Text if activerecord && type.is_a?(ActiveRecord::Type::Serialized) && defined?(ActionText::Content) && type.coder == ActionText::Content message.force_encoding(Encoding::UTF_8) end 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 ankane-lockbox-c8a7a5f/lib/lockbox/padding.rb000066400000000000000000000023461516427617200212700ustar00rootroot00000000000000module 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 ankane-lockbox-c8a7a5f/lib/lockbox/railtie.rb000066400000000000000000000021541516427617200213100ustar00rootroot00000000000000module 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) ActiveStorage::Attached::Changes::CreateOne.prepend(Lockbox::ActiveStorageExtensions::CreateOne) ActiveStorage::Attached::One.prepend(Lockbox::ActiveStorageExtensions::AttachedOne) ActiveStorage::Attached::Many.prepend(Lockbox::ActiveStorageExtensions::AttachedMany) ActiveSupport.on_load(:active_storage_attachment) do prepend Lockbox::ActiveStorageExtensions::Attachment end ActiveSupport.on_load(:active_storage_blob) do prepend Lockbox::ActiveStorageExtensions::Blob end end end end end ankane-lockbox-c8a7a5f/lib/lockbox/utils.rb000066400000000000000000000113611516427617200210170ustar00rootroot00000000000000module 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 unless options.key?(:previous_versions) options[:previous_versions] = Lockbox.default_options[:previous_versions] 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 ankane-lockbox-c8a7a5f/lib/lockbox/version.rb000066400000000000000000000000471516427617200213430ustar00rootroot00000000000000module Lockbox VERSION = "2.2.0" end ankane-lockbox-c8a7a5f/lockbox.gemspec000066400000000000000000000007731516427617200201360ustar00rootroot00000000000000require_relative "lib/lockbox/version" Gem::Specification.new do |spec| spec.name = "lockbox" spec.version = Lockbox::VERSION spec.summary = "Modern encryption for Ruby and Rails" spec.homepage = "https://github.com/ankane/lockbox" spec.license = "MIT" spec.author = "Andrew Kane" spec.email = "andrew@ankane.org" spec.files = Dir["*.{md,txt}", "{lib}/**/*"] spec.require_path = "lib" spec.required_ruby_version = ">= 3.3" end ankane-lockbox-c8a7a5f/test/000077500000000000000000000000001516427617200161005ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/action_text_test.rb000066400000000000000000000017101516427617200220040ustar00rootroot00000000000000require_relative "test_helper" class ActionTextTest < Minitest::Test def setup skip unless defined?(ActionText) end def test_create user = User.create!(content: "hi") assert_equal "
\n hi\n
\n", user.content.body.to_s end # if encrypted, the ciphertext will change when the content is the same def test_encrypted user = User.create!(content: "hi") original_ciphertext = user.content.body_ciphertext user.update!(content: "hi") refute_equal user.content.body_ciphertext, original_ciphertext end def test_encoding user = User.create!(content: "ルビー") assert_equal "
\n ルビー\n
\n", user.content.body.to_s assert_equal user.content.to_trix_html, "ルビー" user.reload assert_equal "
\n ルビー\n
\n", user.content.body.to_s assert_equal user.content.to_trix_html, "ルビー" end end ankane-lockbox-c8a7a5f/test/active_storage_test.rb000066400000000000000000000360231516427617200224670ustar00rootroot00000000000000require_relative "test_helper" class ActiveStorageTest < Minitest::Test def setup skip unless defined?(ActiveStorage) ActiveStorage::VariantRecord.delete_all ActiveStorage::Attachment.delete_all ActiveStorage::Blob.delete_all end def teardown @content = nil @contents = nil end def test_encrypt_one user = User.create!(avatar: attachment) assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download user = User.last assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download assert user.avatar.metadata["encrypted"] end def test_encrypt_uploaded_file user = User.create!(avatar: uploaded_file) assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download user = User.last assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download assert user.avatar.metadata["encrypted"] end def test_encrypt_blank_one user = User.create!(avatar: attachment("")) assert_equal "", user.avatar.download refute_equal "", user.avatar.blob.download user = User.last assert_equal "", user.avatar.download refute_equal "", user.avatar.blob.download end def test_encrypt_blank_uploaded_file user = User.create!(avatar: uploaded_file("")) assert_equal "", user.avatar.download refute_equal "", user.avatar.blob.download user = User.last assert_equal "", user.avatar.download refute_equal "", user.avatar.blob.download end def test_encrypt_blob user = User.create!(avatar: attachment) # blobs are just attached, not (re)encrypted User.create!(avatar: user.avatar.blob) end def test_encrypt_unencrypted_blob unencrypted_blob = User.create!(image: attachment).image.blob # attaches but fails to decrypt user = User.create!(avatar: unencrypted_blob) assert_raises(Lockbox::DecryptionError) do user.avatar.download end end def test_encrypt_unsupported error = assert_raises(ArgumentError) do User.create!(image: 123) end assert_equal "Could not find or build blob: expected attachable, got 123", error.message error = assert_raises(ArgumentError) do User.create!(avatar: 123) end assert_equal "Could not find or build blob: expected attachable, got 123", error.message end def test_encrypt_attach user = User.create! user.avatar.attach(uploaded_file) assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download user = User.last assert_equal content, user.avatar.download refute_equal content, user.avatar.blob.download end def test_encrypt_many user = User.create!(avatars: attachments) assert_equal contents, user.avatars.map(&:download) refute_equal contents, user.avatars.map { |a| a.blob.download } user = User.last assert_equal contents, user.avatars.map(&:download) refute_equal contents, user.avatars.map { |a| a.blob.download } assert user.avatars.all? { |a| a.metadata["encrypted"] } end def test_encrypt_many_attach user = User.create! attachments.each do |attachment| user.avatars.attach(attachment) end assert_equal contents, user.avatars.map(&:download) refute_equal contents, user.avatars.map { |a| a.blob.download } user = User.last assert_equal contents, user.avatars.map(&:download) refute_equal contents, user.avatars.map { |a| a.blob.download } assert user.avatars.all? { |a| a.metadata["encrypted"] } end def test_no_encrypt_one user = User.create!(image: attachment) assert_equal content, user.image.download assert_equal content, user.image.blob.download user = User.last assert_equal content, user.image.download assert_equal content, user.image.blob.download end def test_no_encrypt_one_attach user = User.create! user.image.attach(attachment) assert_equal content, user.image.download assert_equal content, user.image.blob.download user = User.last assert_equal content, user.image.download assert_equal content, user.image.blob.download end def test_no_encrypt_many user = User.create!(images: attachments) assert_equal contents, user.images.map(&:download) assert_equal contents, user.images.map { |a| a.blob.download } user = User.last assert_equal contents, user.images.map(&:download) assert_equal contents, user.images.map { |a| a.blob.download } end # not yet supported # tries to transform the encrypted file def test_encrypt_variant skip if truffleruby? path = "test/support/image.png" User.create!(avatar: {io: File.open(path), filename: "image.png", content_type: "image/png"}) user = User.last error = assert_raises(Lockbox::Error) do user.avatar.variant(**variant_options).processed end assert_equal "Variant not supported for encrypted files", error.message end def test_no_encrypt_variant skip if truffleruby? path = "test/support/image.png" User.create!(image: {io: File.open(path), filename: "image.png", content_type: "image/png"}) user = User.last user.image.variant(**variant_options).processed end # not yet supported # tries to transform the encrypted file # succeeds, but unreadable def test_encrypt_preview path = "test/support/doc.pdf" User.create!(avatar: {io: File.open(path), filename: "doc.pdf", content_type: "application/pdf"}) user = User.last error = assert_raises(Lockbox::Error) do user.avatar.preview(**variant_options).processed.blob.download end assert_equal "Preview not supported for encrypted files", error.message end def test_no_encrypt_preview skip "Requires Poppler or muPDF" unless ENV["CI"] path = "test/support/doc.pdf" User.create!(image: {io: File.open(path), filename: "doc.pdf", content_type: "application/pdf"}) user = User.last contents = user.image.preview(**variant_options).processed.blob.download assert_match "%PDF-1.3", contents end def test_rotate_encryption_one message = "hello world" filename = "test.txt" content_type = "image/png" user = User.create!(avatar: {io: StringIO.new(message.dup), filename: filename, content_type: content_type}) blob = user.avatar.attachment.blob user.avatar.rotate_encryption! assert_equal content_type, user.avatar.content_type assert_equal filename, user.avatar.filename.to_s refute_equal blob, user.avatar.blob assert_equal message, user.avatar.download user = User.last assert_equal content_type, user.avatar.content_type assert_equal filename, user.avatar.filename.to_s refute_equal blob, user.avatar.blob assert_equal message, user.avatar.download end def test_rotate_encryption_many user = User.create!(avatars: attachments) blobs = user.avatars.map(&:blob) user.avatars.rotate_encryption! new_blobs = user.avatars.map(&:blob) refute_equal blobs, new_blobs assert_equal blobs.size, new_blobs.size assert_equal contents, user.avatars.map(&:download) end def test_rotate_encryption_not_attached user = User.create! user.avatar.rotate_encryption! refute user.avatar.attached? end def test_image # run many times to make sure content type is detected correctly iterations = ENV["CI"] ? 1000 : 1 iterations.times do path = "test/support/image.png" user = User.create!(avatar: {io: File.open(path), filename: "image.png", content_type: "image/png"}) assert_equal "image/png", user.avatar.content_type assert_equal "image.png", user.avatar.filename.to_s assert_equal File.binread(path), user.avatar.download user = User.last assert_equal "image/png", user.avatar.content_type assert_equal "image.png", user.avatar.filename.to_s assert_equal File.binread(path), user.avatar.download end end def test_has_one_attached_with_no_encrypted_attachments post = Post.create!(title: "123", photo: attachment) assert_equal content, post.photo.download assert_equal content, post.photo.blob.download end def test_open user = User.create!(avatar: attachment) user.avatar.open do |f| assert_equal content, f.read end end def test_open_blank user = User.create!(avatar: attachment("")) user.avatar.open do |f| assert_equal "", f.read end end def test_download_block user = User.create!(avatar: attachment) chunks = [] user.avatar.download do |chunk| chunks << chunk end assert_equal content, chunks.join end def test_download_chunk user = User.create!(avatar: attachment) # TODO raise error in 3.0 assert_output(nil, /WARNING: download_chunk not supported for encrypted files/) do assert_equal 4, user.avatar.download_chunk(0..3).bytesize end end def test_metadata user = User.create! user.image.attach(attachment.merge(metadata: {"hello" => true})) assert user.image.metadata["hello"] user.avatar.attach(attachment.merge(metadata: {"hello" => true})) assert user.avatar.metadata["hello"] end def test_migrating Comment.destroy_all comment = Comment.create!(image: attachment) assert_equal content, comment.image.download assert_equal content, comment.image.blob.download assert_nil comment.image.metadata["encrypted"] with_migrating(:image) do comment = Comment.last comment.image.attach(attachment) assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] end assert_equal 1, ActiveStorage::Blob.count end def test_migrate_one Comment.destroy_all comment = Comment.create!(image: attachment) assert_equal content, comment.image.download assert_equal content, comment.image.blob.download assert_nil comment.image.metadata["encrypted"] with_migrating(:image) do Lockbox.migrate(Comment) comment = Comment.last assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] comment = Comment.last comment.image.attach(attachment) assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] end assert_equal 1, ActiveStorage::Blob.count end def test_migrate_many Comment.destroy_all comment = Comment.create!(images: attachments) assert_equal contents, comment.images.map(&:download) assert_equal contents, comment.images.map { |image| image.blob.download } assert comment.images.all? { |image| image.metadata["encrypted"].nil? } with_migrating(:images) do Lockbox.migrate(Comment) comment = Comment.last assert_equal 3, comment.images.size assert_equal contents, comment.images.map(&:download) refute_equal contents, comment.images.map { |image| image.blob.download } assert comment.images.all? { |image| image.metadata["encrypted"] } comment = Comment.last new_message = "Test 4" comment.images.attach(attachment(new_message)) assert_equal new_message, comment.images.last.download refute_equal new_message, comment.images.last.blob.download assert comment.images.last.metadata["encrypted"] end assert_equal 4, ActiveStorage::Blob.count end def test_migrate_one_none_attached Comment.destroy_all Comment.create! with_migrating(:image) do Lockbox.migrate(Comment) end end def test_migrate_many_none_attached Comment.destroy_all Comment.create! with_migrating(:images) do Lockbox.migrate(Comment) end end def test_migrate_one_rotate_encryption Comment.destroy_all comment = Comment.create!(image: attachment) assert_equal content, comment.image.download assert_equal content, comment.image.blob.download assert_nil comment.image.metadata["encrypted"] with_migrating(:image) do comment.image.rotate_encryption! assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] comment = Comment.last assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] end assert_equal 1, ActiveStorage::Blob.count end def test_migrate_many_rotate_encryption Comment.destroy_all comment = Comment.create!(images: attachments) assert_equal contents, comment.images.map(&:download) assert_equal contents, comment.images.map { |image| image.blob.download } assert comment.images.all? { |image| image.metadata["encrypted"].nil? } with_migrating(:images) do comment.images.rotate_encryption! assert_equal 3, comment.images.size assert_equal contents, comment.images.map(&:download) refute_equal contents, comment.images.map { |image| image.blob.download } assert comment.images.all? { |image| image.metadata["encrypted"] } comment = Comment.last assert_equal 3, comment.images.size assert_equal contents, comment.images.map(&:download) refute_equal contents, comment.images.map { |image| image.blob.download } assert comment.images.all? { |image| image.metadata["encrypted"] } end assert_equal 3, ActiveStorage::Blob.count end def test_migrate_relation Comment.destroy_all comment = Comment.create!(image: attachment) comment2 = Comment.create!(image: attachment) assert_nil comment.image.metadata["encrypted"] assert_nil comment2.image.metadata["encrypted"] with_migrating(:image) do Lockbox.migrate(Comment.where(id: comment.id)) comment.reload comment2.reload assert_equal content, comment.image.download refute_equal content, comment.image.blob.download assert comment.image.metadata["encrypted"] assert_equal content, comment2.image.download assert_equal content, comment2.image.blob.download assert_nil comment2.image.metadata["encrypted"] end assert_equal 2, ActiveStorage::Blob.count end def with_migrating(name) Comment.instance_variable_get(:@lockbox_attachments)[name] = {migrating: true} yield ensure Comment.instance_variable_get(:@lockbox_attachments).delete(name) end def content @content ||= "Test #{rand(1000)}" end def contents @contents ||= 3.times.map { "Test #{rand(1000)}" } end def attachment(content = nil) content ||= self.content {io: StringIO.new(content.dup), filename: "#{content.downcase.gsub(" ", "-")}.txt"} end def attachments contents.map { |c| attachment(c) } end def uploaded_file(content = nil) content ||= self.content file = Tempfile.new file.write(content) file.rewind ActionDispatch::Http::UploadedFile.new( filename: "#{content.downcase.gsub(" ", "-")}.txt", tempfile: file ) end def variant_options {resize_to_fit: [500, 500]} end end ankane-lockbox-c8a7a5f/test/carrier_wave_test.rb000066400000000000000000000104441516427617200221400ustar00rootroot00000000000000require_relative "test_helper" class CarrierWaveTest < Minitest::Test def teardown @content = nil end def test_encrypt uploader = TextUploader.new uploader.store!(uploaded_file) assert_equal "#{content}!!", uploader.read refute_equal uploader.file.read, uploader.read assert_equal "#{content}!!..", uploader.thumb.read refute_equal uploader.thumb.file.read, uploader.thumb.read end def test_no_encrypt uploader = ImageUploader.new uploader.store!(uploaded_file) assert_equal "#{content}!!", uploader.read assert_equal uploader.file.read, uploader.read assert_equal "#{content}!!..", uploader.thumb.read assert_equal uploader.thumb.file.read, uploader.thumb.read end def test_rotate_encryption file = uploaded_file uploader = TextUploader.new uploader.store!(file) ciphertext = uploader.file.read thumb_ciphertext = uploader.thumb.file.read uploader = TextUploader.new uploader.retrieve_from_store!(File.basename(file.path)) uploader.rotate_encryption! refute_equal ciphertext, uploader.file.read assert_equal "#{content}!!", uploader.read refute_equal thumb_ciphertext, uploader.thumb.file.read assert_equal "#{content}!!..", uploader.thumb.read assert uploader.enable_processing end def test_image uploader = AvatarUploader.new uploader.store!(image_file) assert_equal "image/png", uploader.content_type assert_equal image_content, uploader.read uploader = AvatarUploader.new uploader.retrieve_from_store!("image.png") assert_equal "image/png", uploader.content_type assert_equal image_content, uploader.read end def test_mounted skip if mongoid? user = User.create!(document: image_file) assert_equal image_content, user.document.read assert_equal "image/png", user.document.content_type refute_equal image_content, user.document.file.read user = User.last assert_equal image_content, user.document.read assert_equal "image/png", user.document.content_type refute_equal image_content, user.document.file.read end def test_mounted_many skip if mongoid? user = User.create!(documents: [image_file]) assert_equal image_content, user.documents.first.read assert_equal "image/png", user.documents.first.content_type refute_equal image_content, user.documents.first.file.read user = User.last assert_equal image_content, user.documents.first.read assert_equal "image/png", user.documents.first.content_type refute_equal image_content, user.documents.first.file.read end def test_mounted_rotate_encryption skip if mongoid? user = User.create!(document: image_file) assert_equal image_content, user.document.read assert_equal "image/png", user.document.content_type refute_equal image_content, user.document.file.read ciphertext = user.document.file.read user.document.rotate_encryption! user = User.last assert_equal image_content, user.document.read assert_equal "image/png", user.document.content_type refute_equal image_content, user.document.file.read refute_equal ciphertext, user.document.file.read end def test_mounted_many_rotate_encryption skip if mongoid? user = User.create!(documents: [image_file]) assert_equal image_content, user.documents.first.read assert_equal "image/png", user.documents.first.content_type refute_equal image_content, user.documents.first.file.read ciphertext = user.documents.first.file.read user.documents.map(&:rotate_encryption!) user = User.last assert_equal image_content, user.documents.first.read assert_equal "image/png", user.documents.first.content_type refute_equal image_content, user.documents.first.file.read refute_equal ciphertext, user.documents.first.file.read end def test_lockbox_options assert_equal({}, TextUploader.lockbox_options) assert_equal({}, AvatarUploader.lockbox_options) refute ImageUploader.respond_to?(:lockbox_options) end def content @content ||= "Test #{rand(1000)}" end def uploaded_file file = Tempfile.new file.write(content) file.rewind file end def image_content File.binread("test/support/image.png") end def image_file File.open("test/support/image.png", "rb") end end ankane-lockbox-c8a7a5f/test/insert_test.rb000066400000000000000000000042371516427617200207760ustar00rootroot00000000000000require_relative "test_helper" class InsertTest < Minitest::Test def setup skip if mongoid? User.delete_all end def test_insert User.insert({name: "Test", email: "test@example.org"}) User.insert({"name" => "New", "email" => "new@example.org"}) users = User.order(:id).pluck(:name, :email) expected = [["Test", "test@example.org"], ["New", "new@example.org"]] assert_equal expected, users end def test_insert! User.insert!({name: "Test", email: "test@example.org"}) User.insert!({"name" => "New", "email" => "new@example.org"}) users = User.order(:id).pluck(:name, :email) expected = [["Test", "test@example.org"], ["New", "new@example.org"]] assert_equal expected, users end def test_insert_all User.insert_all([{name: "Test", email: "test@example.org"}]) User.insert_all([{"name" => "New", "email" => "new@example.org"}]) users = User.order(:id).pluck(:name, :email) expected = [["Test", "test@example.org"], ["New", "new@example.org"]] assert_equal expected, users end def test_insert_all! User.insert_all!([{name: "Test", email: "test@example.org"}]) User.insert_all!([{"name" => "New", "email" => "new@example.org"}]) users = User.order(:id).pluck(:name, :email) expected = [["Test", "test@example.org"], ["New", "new@example.org"]] assert_equal expected, users end def test_upsert User.upsert({id: 1, name: "Test", email: "test@example.org"}) User.upsert({"id" => 1, "name" => "New", "email" => "new@example.org"}) users = User.order(:id).pluck(:name, :email) expected = [["New", "new@example.org"]] assert_equal expected, users end def test_upsert_all User.upsert_all([{id: 1, name: "Test", email: "test@example.org"}]) User.upsert_all([{"id" => 1, "name" => "New", "email" => "new@example.org"}]) users = User.order(:id).pluck(:name, :email) expected = [["New", "new@example.org"]] assert_equal expected, users end def test_symbol_options error = assert_raises(Lockbox::Error) do Admin.upsert({email: "test@example.org"}) end assert_equal "Not available since :key depends on record", error.message end end ankane-lockbox-c8a7a5f/test/internal/000077500000000000000000000000001516427617200177145ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/internal/app/000077500000000000000000000000001516427617200204745ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/internal/app/controllers/000077500000000000000000000000001516427617200230425ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/internal/app/controllers/application_controller.rb000066400000000000000000000000711516427617200301330ustar00rootroot00000000000000class ApplicationController < ActionController::Base end ankane-lockbox-c8a7a5f/test/internal/config/000077500000000000000000000000001516427617200211615ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/internal/config/database.yml000066400000000000000000000003711516427617200234510ustar00rootroot00000000000000test: adapter: <%= ENV["ADAPTER"] || "sqlite3" %> database: <%= ["postgresql", "mysql2", "trilogy"].include?(ENV["ADAPTER"]) ? "lockbox_test" : "db/combustion_test.sqlite" %> <% if ENV["ADAPTER"] == "trilogy" %> host: 127.0.0.1 <% end %> ankane-lockbox-c8a7a5f/test/internal/config/routes.rb000066400000000000000000000001661516427617200230320ustar00rootroot00000000000000Rails.application.routes.draw do # Add your own routes here, or remove this file if you don't have need for it. end ankane-lockbox-c8a7a5f/test/internal/config/storage.yml000066400000000000000000000000531516427617200233460ustar00rootroot00000000000000test: service: Disk root: /tmp/storage ankane-lockbox-c8a7a5f/test/internal/db/000077500000000000000000000000001516427617200203015ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/internal/db/schema.rb000066400000000000000000000073061516427617200220740ustar00rootroot00000000000000ActiveRecord::Schema.define do create_table :action_text_rich_texts do |t| t.string :name, null: false t.text :body_ciphertext t.references :record, null: false, polymorphic: true, index: false t.timestamps t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table :active_storage_blobs do |t| t.string :key, null: false t.string :filename, null: false t.string :content_type t.text :metadata t.bigint :byte_size, null: false t.string :checksum, null: false t.datetime :created_at, null: false t.string :service_name t.index [ :key ], unique: true end create_table :active_storage_attachments do |t| t.string :name, null: false t.references :record, null: false, polymorphic: true, index: false t.references :blob, null: false t.datetime :created_at, null: false t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true t.foreign_key :active_storage_blobs, column: :blob_id end create_table :active_storage_variant_records do |t| t.belongs_to :blob, null: false, index: false t.string :variation_digest, null: false t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true t.foreign_key :active_storage_blobs, column: :blob_id end create_table :users do |t| t.string :name t.string :document t.string :documents t.text :email_ciphertext t.text :phone_ciphertext t.text :properties t.text :properties2_ciphertext t.text :settings t.text :settings2_ciphertext t.text :messages t.text :messages2_ciphertext t.string :country t.text :country2_ciphertext t.boolean :active t.text :active2_ciphertext t.date :born_on t.text :born_on2_ciphertext t.datetime :signed_at t.text :signed_at2_ciphertext t.time :opens_at t.text :opens_at2_ciphertext t.bigint :sign_in_count t.text :sign_in_count2_ciphertext t.float :latitude t.text :latitude2_ciphertext if ["mysql2", "trilogy"].include?(ENV["ADAPTER"]) t.decimal :longitude, precision: 65, scale: 30 else t.decimal :longitude end t.text :longitude2_ciphertext t.binary :video t.text :video2_ciphertext t.column :data, :json t.text :data2_ciphertext t.text :info t.text :info2_ciphertext t.text :credentials t.text :credentials2_ciphertext t.text :credentials3 t.text :configuration t.text :configuration2_ciphertext t.text :coordinates t.text :coordinates2_ciphertext if ENV["ADAPTER"] == "postgresql" t.inet :ip t.text :ip2_ciphertext end t.text :config t.text :config2_ciphertext t.text :conf_ciphertext t.text :city_ciphertext t.binary :ssn_ciphertext t.text :region_ciphertext t.text :state t.text :state_ciphertext t.text :photo_data end create_table :posts do |t| t.text :title_ciphertext end create_table :robots do |t| t.text :name t.text :email t.text :properties t.text :name_ciphertext t.text :email_ciphertext t.text :properties_ciphertext end create_table :comments do |t| end create_table :admins do |t| t.text :name t.text :email_ciphertext t.text :personal_email_ciphertext t.text :other_email_ciphertext t.text :email_address_ciphertext t.text :encrypted_email end create_table :agents do |t| t.text :name t.text :email_ciphertext t.text :personal_email_ciphertext end create_table :people do |t| t.text :data_ciphertext end end ankane-lockbox-c8a7a5f/test/lockbox_test.rb000066400000000000000000000345521516427617200211360ustar00rootroot00000000000000require_relative "test_helper" class LockboxTest < Minitest::Test def test_works lockbox = Lockbox.new(key: random_key) message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) assert_equal Encoding::UTF_8, message.encoding assert_equal Encoding::BINARY, ciphertext.encoding assert_equal Encoding::BINARY, lockbox.decrypt(ciphertext).encoding end def test_same_message_different_ciphertext lockbox = Lockbox.new(key: random_key) message = "it works!" refute_equal lockbox.encrypt(message), lockbox.encrypt(message) end def test_encrypt_nil lockbox = Lockbox.new(key: random_key) assert_raises(TypeError) do lockbox.encrypt(nil) end end def test_decrypt_nil lockbox = Lockbox.new(key: random_key) assert_raises(TypeError) do lockbox.decrypt(nil) end end def test_encrypt_non_string lockbox = Lockbox.new(key: random_key) error = assert_raises(TypeError) do lockbox.encrypt(1) end assert_equal "can't convert Integer to String", error.message end def test_decrypt_non_string lockbox = Lockbox.new(key: random_key) error = assert_raises(TypeError) do lockbox.decrypt(1) end assert_equal "can't convert Integer to String", error.message end def test_encrypt_empty_string_aes_gcm lockbox = Lockbox.new(key: random_key) ciphertext = lockbox.encrypt("") assert_equal 12 + 16, ciphertext.bytesize assert_equal "", lockbox.decrypt(ciphertext) end def test_encrypt_empty_string_aes_gcm_low_level skip if jruby? aes_gcm = Lockbox::AES_GCM.new("\x0".b * 32) ciphertext = aes_gcm.encrypt("\x0".b * 12, "", nil) assert_equal "530f8afbc74536b9a963b4f1c4cb738b", Lockbox.to_hex(ciphertext) end def test_encrypt_empty_string_aes_gcm_low_level_different_nonce aes_gcm = Lockbox::AES_GCM.new("\x0".b * 32) ciphertext = aes_gcm.encrypt("\xff".b * 12, "", nil) assert_equal "51d37f94ccb639299b7ac25fe0bfd765", Lockbox.to_hex(ciphertext) end def test_encrypt_empty_string_xsalsa20 lockbox = Lockbox.new(key: random_key, algorithm: "xsalsa20") ciphertext = lockbox.encrypt("") assert_equal 24 + 16, ciphertext.bytesize assert_equal "", lockbox.decrypt(ciphertext) end def test_default_algorithm key = random_key encrypt_box = Lockbox.new(key: key) message = "it works!" * 10000 ciphertext = encrypt_box.encrypt(message) decrypt_box = Lockbox.new(key: key, algorithm: "aes-gcm") assert_equal message, decrypt_box.decrypt(ciphertext) end def test_aes_gcm_associated_data lockbox = Lockbox.new(key: random_key, algorithm: "aes-gcm") message = "it works!" associated_data = "boom" ciphertext = lockbox.encrypt(message, associated_data: associated_data) assert_equal message, lockbox.decrypt(ciphertext, associated_data: associated_data) assert_raises(Lockbox::DecryptionError) do lockbox.decrypt(ciphertext, associated_data: "bad") end end def test_xsalsa20 lockbox = Lockbox.new(key: random_key, algorithm: "xsalsa20") message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) assert_equal Encoding::UTF_8, message.encoding assert_equal Encoding::BINARY, ciphertext.encoding assert_equal Encoding::BINARY, lockbox.decrypt(ciphertext).encoding end def test_xsalsa20_associated_data lockbox = Lockbox.new(key: random_key, algorithm: "xsalsa20") error = assert_raises(ArgumentError) do lockbox.encrypt("it works!", associated_data: "boom") end assert_equal "Associated data not supported with this algorithm", error.message end def test_xchacha20 lockbox = Lockbox.new(key: random_key, algorithm: "xchacha20") message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) assert_equal Encoding::UTF_8, message.encoding assert_equal Encoding::BINARY, ciphertext.encoding assert_equal Encoding::BINARY, lockbox.decrypt(ciphertext).encoding end def test_xchacha20_associated_data lockbox = Lockbox.new(key: random_key, algorithm: "xchacha20") message = "it works!" associated_data = "boom" ciphertext = lockbox.encrypt(message, associated_data: associated_data) assert_equal message, lockbox.decrypt(ciphertext, associated_data: associated_data) assert_raises(Lockbox::DecryptionError) do lockbox.decrypt(ciphertext, associated_data: "bad") end end def test_hybrid key_pair = Lockbox.generate_key_pair lockbox = Lockbox.new(algorithm: "hybrid", encryption_key: key_pair[:encryption_key]) message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) lockbox = Lockbox.new(algorithm: "hybrid", decryption_key: key_pair[:decryption_key]) assert_equal message, lockbox.decrypt(ciphertext) end def test_hybrid_swapped key_pair = Lockbox.generate_key_pair lockbox = Lockbox.new(algorithm: "hybrid", encryption_key: key_pair[:decryption_key]) message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) lockbox = Lockbox.new(algorithm: "hybrid", decryption_key: key_pair[:encryption_key]) assert_raises(Lockbox::DecryptionError) do lockbox.decrypt(ciphertext) end end def test_hybrid_no_encryption_key key_pair = Lockbox.generate_key_pair lockbox = Lockbox.new(algorithm: "hybrid", decryption_key: key_pair[:decryption_key]) error = assert_raises(ArgumentError) do lockbox.encrypt("it works!") end assert_equal "No encryption key set", error.message end def test_hybrid_no_decryption_key key_pair = Lockbox.generate_key_pair lockbox = Lockbox.new(algorithm: "hybrid", encryption_key: key_pair[:encryption_key]) error = assert_raises(ArgumentError) do lockbox.decrypt("it works!") end assert_equal "No decryption key set", error.message end def test_bad_algorithm error = assert_raises(ArgumentError) do Lockbox.new(key: random_key, algorithm: "bad") end assert_includes error.message, "Unknown algorithm" end def test_bad_ciphertext lockbox = Lockbox.new(key: random_key) assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0") end assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0"*16) end assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0"*100) end end def test_bad_ciphertext_xchacha20 lockbox = Lockbox.new(key: random_key, algorithm: "xchacha20") assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0") end assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0"*16) end assert_raises(Lockbox::DecryptionError) do lockbox.decrypt("0"*100) end end def test_rotation key = random_key lockbox = Lockbox.new(key: key) message = "it works!" ciphertext = lockbox.encrypt(message) new_box = Lockbox.new(key: random_key, previous_versions: [{key: key}]) assert_equal message, new_box.decrypt(ciphertext) end def test_rotation_padding_only key = random_key lockbox = Lockbox.new(key: key) message = "it works!" ciphertext = lockbox.encrypt(message) new_box = Lockbox.new(key: key, padding: true, previous_versions: [{key: key}]) assert_equal message, new_box.decrypt(ciphertext) # returning DecryptionError instead of PaddingError # is for end-user convenience, not for security assert_raises Lockbox::DecryptionError do Lockbox.new(key: key, padding: true).decrypt(ciphertext) end end def test_inspect lockbox = Lockbox.new(key: random_key) refute_includes lockbox.inspect, "key" refute_includes lockbox.to_s, "key" end def test_xsalsa20_inspect lockbox = Lockbox.new(key: random_key, algorithm: "xsalsa20") refute_includes lockbox.inspect, "key" refute_includes lockbox.to_s, "key" end def test_xchacha20_inspect lockbox = Lockbox.new(key: random_key, algorithm: "xchacha20") refute_includes lockbox.inspect, "key" refute_includes lockbox.to_s, "key" end def test_decrypt_utf8 lockbox = Lockbox.new(key: random_key) message = "it works!" ciphertext = lockbox.encrypt(message) ciphertext.force_encoding(Encoding::UTF_8) assert_equal message, lockbox.decrypt(ciphertext) end def test_xsalsa20_decrypt_utf8 lockbox = Lockbox.new(key: random_key, algorithm: "xsalsa20") message = "it works!" ciphertext = lockbox.encrypt(message) ciphertext.force_encoding(Encoding::UTF_8) assert_equal message, lockbox.decrypt(ciphertext) end def test_xchacha20_decrypt_utf8 lockbox = Lockbox.new(key: random_key, algorithm: "xchacha20") message = "it works!" ciphertext = lockbox.encrypt(message) ciphertext.force_encoding(Encoding::UTF_8) assert_equal message, lockbox.decrypt(ciphertext) end def test_hex_key lockbox = Lockbox.new(key: SecureRandom.hex(32)) message = "it works!" ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) end def test_uppercase_hex_key lockbox = Lockbox.new(key: SecureRandom.hex(32).upcase) message = "it works!" ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) end def test_xsalsa20_hex_key lockbox = Lockbox.new(key: SecureRandom.hex(32), algorithm: "xsalsa20") message = "it works!" ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) end def test_xchacha20_hex_key lockbox = Lockbox.new(key: SecureRandom.hex(32), algorithm: "xchacha20") message = "it works!" ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt(ciphertext) end def test_encrypt_file lockbox = Lockbox.new(key: SecureRandom.hex(32)) message = "it works!" file = Tempfile.new file.write(message) file.rewind ciphertext = lockbox.encrypt(file) assert_equal message, lockbox.decrypt(ciphertext) end def test_decrypt_file lockbox = Lockbox.new(key: SecureRandom.hex(32)) message = "it works!" ciphertext = lockbox.encrypt(message) file = Tempfile.new(encoding: Encoding::BINARY) file.write(ciphertext) file.rewind assert_equal message, lockbox.decrypt(file) end def test_attribute_key key = Lockbox.attribute_key(table: "users", attribute: "license", master_key: "0"*64) assert_equal "d96ffa3fe916b3a9b57d084f5781e95748333b877e32e6399e387d3d75b238a1", key end def test_attribute_key_encode_false key = Lockbox.attribute_key(table: "users", attribute: "license", master_key: "0"*64, encode: false) assert_equal ["d96ffa3fe916b3a9b57d084f5781e95748333b877e32e6399e387d3d75b238a1"].pack("H*"), key end def test_padding lockbox = Lockbox.new(key: random_key, padding: true) message = "it works!" ciphertext = lockbox.encrypt(message) # nonce + ciphertext + auth tag assert_equal 12 + 16 + 16, ciphertext.bytesize assert_equal message, lockbox.decrypt(ciphertext) end def test_padding_integer lockbox = Lockbox.new(key: random_key, padding: 13) message = "it works!" ciphertext = lockbox.encrypt(message) # nonce + ciphertext + auth tag assert_equal 12 + 13 + 16, ciphertext.bytesize assert_equal message, lockbox.decrypt(ciphertext) end def test_padding_invalid_size assert_raises ArgumentError do Lockbox.pad("hi", size: 0) end end def test_pad assert_equal "80000000000000000000000000000000", Lockbox.to_hex(Lockbox.pad("")) assert_equal "6162636465666768696a6b6c6d6e6f80", Lockbox.to_hex(Lockbox.pad("abcdefghijklmno")) assert_equal "6162636465666768696a6b6c6d6e6f7080000000000000000000000000000000", Lockbox.to_hex(Lockbox.pad("abcdefghijklmnop")) end def test_unpad assert_equal "", Lockbox.unpad(Lockbox.pad("")) assert_equal "abcdefghijklmno", Lockbox.unpad(Lockbox.pad("abcdefghijklmno")) assert_equal "abcdefghijklmnop", Lockbox.unpad(Lockbox.pad("abcdefghijklmnop")) end def test_unpad_invalid error = assert_raises(Lockbox::PaddingError) do Lockbox.unpad("hi") end assert_equal "Invalid padding", error.message end def test_encrypt_io lockbox = Lockbox.new(key: random_key) file = File.open("test/support/image.png", "rb") ciphertext_io = lockbox.encrypt_io(file) assert_equal "image.png", ciphertext_io.original_filename assert_nil ciphertext_io.content_type file.rewind ciphertext_io.rewind refute_equal file.read, ciphertext_io.read file.rewind ciphertext_io.rewind assert_equal file.read, lockbox.decrypt_io(ciphertext_io).read end def test_decrypt_str lockbox = Lockbox.new(key: random_key) message = "it works!" * 10000 ciphertext = lockbox.encrypt(message) assert_equal message, lockbox.decrypt_str(ciphertext) assert_equal Encoding::UTF_8, message.encoding assert_equal Encoding::BINARY, ciphertext.encoding assert_equal Encoding::UTF_8, lockbox.decrypt_str(ciphertext).encoding end # ensure we can decrypt values from previous versions of Lockbox # other tests encrypt, then decrypt, so they won't catch this def test_decrypt_not_broken key = "0"*64 lockbox = Lockbox.new(key: key, encode: true) assert_equal "it works!", lockbox.decrypt("4nz8vb+KROTD6l9DvxanuOqn9OJWy7LpLDTKHHoM9Ll0lx+FAg==") end def test_key_bad_length error = assert_raises(Lockbox::Error) do Lockbox.new(key: SecureRandom.hex(31)) end assert_equal "Key must be 32 bytes (64 hex digits)", error.message end def test_key_bad_encoding error = assert_raises(Lockbox::Error) do Lockbox.new(key: SecureRandom.hex(16)) end assert_equal "Key must use binary encoding", error.message end def test_master_key_bad_length with_master_key(SecureRandom.hex(31)) do error = assert_raises(Lockbox::Error) do Lockbox.attribute_key(table: "users", attribute: "test") end assert_equal "Master key must be 32 bytes (64 hex digits)", error.message end end def test_master_key_bad_encoding with_master_key(SecureRandom.hex(16)) do error = assert_raises(Lockbox::Error) do Lockbox.attribute_key(table: "users", attribute: "test") end assert_equal "Master key must use binary encoding", error.message end end private def random_key Lockbox.generate_key end end ankane-lockbox-c8a7a5f/test/migrate_test.rb000066400000000000000000000063161516427617200211220ustar00rootroot00000000000000require_relative "test_helper" class MigrateTest < Minitest::Test def setup Robot.delete_all end def test_migrate 10.times do |i| Robot.create!(name: "User #{i}", email: "test#{i}@example.org") end Robot.update_all(name_ciphertext: nil, email_ciphertext: nil) Lockbox.migrate(Robot, batch_size: 5) robot = Robot.last assert_equal robot.name, robot.migrated_name assert_equal robot.email, robot.migrated_email end def test_migrate_relation robots = ["Hi", "Bye"].map { |v| Robot.create!(name: v) } Robot.update_all(name_ciphertext: nil) Lockbox.migrate(Robot.where(id: robots.first.id)) robots.map(&:reload) assert_equal robots.first.name, robots.first.migrated_name assert_nil robots.last.migrated_name end def test_migrate_restart 10.times do |i| Robot.create!(name: "User #{i}", email: "test#{i}@example.org") end Robot.update_all(name_ciphertext: nil, email_ciphertext: nil) Lockbox.migrate(Robot) Lockbox.migrate(Robot, restart: true) robot = Robot.last assert_equal robot.name, robot.migrated_name assert_equal robot.email, robot.migrated_email end def test_migrating_assignment Robot.create!(name: "Hi") Robot.update_all(name_ciphertext: nil) robot = Robot.last robot.name = "Bye" assert_equal "Bye", robot.migrated_name robot.save(validate: false) assert_equal "Bye", Robot.last.migrated_name end def test_migrating_update_column skip if mongoid? robot = Robot.create!(name: "Hi") robot.update_column(:name, "Bye") assert_equal "Bye", robot.name assert_equal "Bye", robot.migrated_name end def test_migrating_update_columns skip if mongoid? robot = Robot.create!(name: "Hi") robot.update_columns(name: "Bye") assert_equal "Bye", robot.name assert_equal "Bye", robot.migrated_name end def test_migrating_restore_reset robot = Robot.create!(name: "Hi") robot.name = "Bye" if mongoid? robot.reset_name! else robot.restore_name! end assert_equal "Hi", robot.migrated_name end def test_migrate_nothing Lockbox.migrate(Post) end def test_migrate_serialized skip if mongoid? 10.times do |i| Robot.create!(properties: ["hi", "bye"]) end Robot.update_all(properties_ciphertext: nil) Lockbox.migrate(Robot, batch_size: 5) robot = Robot.last # deserialization should work on migrated attributes assert_equal robot.properties, robot.migrated_properties end def test_inspect robot = Robot.create!(email: "test@example.org") assert_includes robot.inspect, "migrated_email: [FILTERED]" refute_includes robot.inspect, "email_ciphertext" # still shows up for original attribute assert_includes robot.inspect, "test@example.org" end def test_filter_attributes skip if mongoid? assert_includes Robot.filter_attributes, /\Amigrated_email\z/ refute_includes Robot.filter_attributes, /\Aemail_ciphertext/ # user likely wants original attribute in filter_attributes as well # but should already be there if it's sensitive # and the attribute isn't managed by Lockbox yet refute_includes Robot.filter_attributes, /\Aemail\z/ end end ankane-lockbox-c8a7a5f/test/model_test.rb000066400000000000000000000527731516427617200206020ustar00rootroot00000000000000require_relative "test_helper" class ModelTest < Minitest::Test def setup User.delete_all end def teardown # very important!! # ensure no plaintext attributes exist assert_no_plaintext_attributes if mongoid? end def test_symmetric email = "test@example.org" User.create!(email: email) user = User.last assert_equal email, user.email end def test_decrypt_after_destroy email = "test@example.org" User.create!(email: email) user = User.last user.destroy! user.email end def test_was_bad_ciphertext user = User.create!(email_ciphertext: "bad") assert_raises Lockbox::DecryptionError do user.email_was end end def test_utf8 email = "Łukasz" User.create!(email: email) user = User.last assert_equal email, user.email end def test_non_utf8 email = "hi \255" User.create!(email: email) user = User.last assert_equal email, user.email end # ensure consistent with normal attributes # https://github.com/rails/rails/blob/master/activemodel/lib/active_model/dirty.rb def test_dirty original_name = "Test" original_email = "test@example.org" new_name = "New" new_email = "new@example.org" user = User.create!(name: original_name, email: original_email) user = User.last original_email_ciphertext = user.email_ciphertext assert !user.name_changed? assert !user.email_changed? assert !user.changed? assert_nil user.name_change assert_nil user.email_change assert_equal original_name, user.name_was assert_equal original_email, user.email_was # in database if !mongoid? assert_equal original_name, user.name_in_database assert_equal original_email, user.email_in_database else assert !user.respond_to?(:name_in_database) assert !user.respond_to?(:email_in_database) end assert !user.name_changed? assert !user.email_changed? assert !user.changed? assert_equal [], user.changed if !mongoid? assert !user.will_save_change_to_name? assert !user.will_save_change_to_email? end # update user.name = new_name user.email = new_email if !mongoid? assert user.will_save_change_to_name? assert user.will_save_change_to_email? end # ensure changed assert user.name_changed? assert user.email_changed? assert user.changed? if mongoid? assert_equal ["email_ciphertext", "name"], user.changed.sort else assert_equal ["email", "email_ciphertext", "name"], user.changed.sort end # ensure was assert_equal original_name, user.name_was assert_equal original_email, user.email_was # ensure in database if !mongoid? assert_equal original_name, user.name_in_database assert_equal original_email, user.email_in_database else assert !user.respond_to?(:name_in_database) assert !user.respond_to?(:email_in_database) end # ensure changes assert_equal [original_name, new_name], user.name_change assert_equal [original_email, new_email], user.email_change assert_equal [original_name, new_name], user.changes["name"] assert_equal [original_email, new_email], user.changes["email"] unless mongoid? # ensure final value assert_equal new_name, user.name assert_equal new_email, user.email refute_equal original_email_ciphertext, user.email_ciphertext # save user.save! # ensure previous changes assert_equal [original_name, new_name], user.previous_changes["name"] assert_equal [original_email, new_email], user.previous_changes["email"] unless mongoid? end def test_dirty_before_last_save skip if mongoid? original_name = "Test" original_email = "test@example.org" new_name = "New" new_email = "new@example.org" user = User.create!(name: original_name, email: original_email) user = User.last assert !user.name_previously_changed? assert !user.email_previously_changed? user.update!(name: new_name, email: new_email) assert user.name_previously_changed? assert user.email_previously_changed? assert_equal [original_name, new_name], user.name_previous_change assert_equal [original_email, new_email], user.email_previous_change assert_equal original_name, user.name_previously_was assert_equal original_email, user.email_previously_was # ensure updated assert_equal original_name, user.name_before_last_save assert_equal original_email, user.email_before_last_save end def test_dirty_bad_ciphertext user = User.create!(email_ciphertext: "bad") assert_output(nil, /Decrypting previous value failed/) do user.email = "test@example.org" end if mongoid? assert user.email_changed? else assert_nil user.email_was end end def test_dirty_nil user = User.new assert_nil user.email user.email = "test@example.org" assert_nil user.email_was assert_nil user.changes["email"][0] unless mongoid? user.email = "new@example.org" assert_nil user.email_was assert_nil user.changes["email"][0] unless mongoid? user.email = nil assert_nil user.email_was assert_empty user.changes unless mongoid? end def test_dirty_type_cast skip if mongoid? user = User.create!(signed_at2: Time.now) user = User.last user.signed_at2 = Time.now assert_kind_of Time, user.signed_at2_was end def test_attributes skip if mongoid? User.create!(email: "test@example.org") user = User.last assert_equal "test@example.org", user.attributes["email"] end def test_attributes_not_loaded skip if mongoid? User.create!(email: "test@example.org") user = User.select("id", "phone_ciphertext").last assert_nil user.attributes["email"] assert !user.has_attribute?("name") assert !user.has_attribute?(:name) assert_equal ["id", "phone_ciphertext", "phone"], user.attributes.keys assert_equal ["id", "phone_ciphertext", "phone"], user.attribute_names assert user.has_attribute?("phone_ciphertext") assert user.has_attribute?(:phone_ciphertext) assert user.has_attribute?("phone") assert user.has_attribute?(:phone) assert !user.has_attribute?("email") assert !user.has_attribute?(:email) assert user.attribute_present?(:id) assert !user.attribute_present?(:email) user = User.select("id AS email_ciphertext").last assert_raises(Lockbox::DecryptionError) do user.attributes end end def test_attributes_bad_ciphertext skip if mongoid? User.create!(email_ciphertext: "bad") user = User.last assert_raises(Lockbox::DecryptionError) do user.attributes end end def test_attributes_default skip if mongoid? _, stderr = capture_io do Admin.has_encrypted :code end assert_match "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead.", stderr end def test_keyed_getter skip if mongoid? user = User.create!(name: "Test", email: "test@example.org") assert_equal "Test", user[:name] assert_equal "Test", user["name"] assert_equal "test@example.org", user[:email] assert_equal "test@example.org", user["email"] user = User.last assert_equal "Test", user[:name] assert_equal "Test", user["name"] assert_equal "test@example.org", user[:email] assert_equal "test@example.org", user["email"] end def test_keyed_setter skip if mongoid? user = User.create! user[:name] = "Test" user[:email] = "test@example.org" user.save! user = User.last assert_equal "Test", user.name assert_equal "test@example.org", user.email end def test_inspect user = User.create!(email: "test@example.org") assert_includes user.inspect, "email: [FILTERED]" refute_includes user.inspect, "email_ciphertext" refute_includes user.inspect, "test@example.org" end # follow same behavior as filter_attributes def test_inspect_nil user = User.new if mongoid? refute_includes user.inspect, "email" else assert_includes user.inspect, "email: nil" end refute_includes user.inspect, "email_ciphertext" refute_includes user.inspect, "test@example.org" end def test_inspect_select return if mongoid? User.create!(email: "test@example.org") user = User.select(:id).last refute_includes user.inspect, "email" refute_includes user.inspect, "email_ciphertext" refute_includes user.inspect, "test@example.org" end def test_inspect_select_ciphertext return if mongoid? User.create!(email: "test@example.org") user = User.select(:id, :email_ciphertext).last assert_includes user.inspect, "email: [FILTERED]" refute_includes user.inspect, "email_ciphertext" refute_includes user.inspect, "test@example.org" end def test_inspect_filter_attributes skip if mongoid? previous_value = User.filter_attributes begin User.filter_attributes = ["name"] user = User.create!(name: "Test") assert_includes user.inspect, "name: [FILTERED]" refute_includes user.inspect, "Test" # Active Record still shows nil for filtered attributes user = User.create!(name: nil) assert_includes user.inspect, "name: nil" ensure User.filter_attributes = previous_value end end def test_serializable_hash user = User.create!(email: "test@example.org") assert_nil user.serializable_hash["email"] assert_nil user.serializable_hash["email_ciphertext"] end def test_to_json user = User.create!(email: "test@example.org") assert_nil user.as_json["email"] assert_nil user.as_json["email_ciphertext"] refute_includes user.to_json, "email" refute_includes user.to_json, "test@example.org" assert_equal "test@example.org", user.as_json(methods: [:email])["email"] end def test_filter_attributes skip if mongoid? assert_includes User.filter_attributes, /\Aemail\z/ refute_includes User.filter_attributes, /\Aemail_ciphertext\z/ end def test_reload original_email = "test@example.org" new_email = "new@example.org" user = User.create!(email: original_email) user.email = new_email assert_equal new_email, user.email assert_equal new_email, user.attributes["email"] unless mongoid? # reload user.reload # loaded # ensure attributes is set before we call email assert_equal original_email, user.attributes["email"] unless mongoid? assert_equal original_email, user.email end def test_update_column skip if mongoid? user = User.create!(name: "Test", email: "test@example.org") user.update_column(:name, "New") assert_equal "New", user.name user.update_column(:email, "new@example.org") assert_equal "new@example.org", user.email user = User.last assert_equal "New", user.name assert_equal "new@example.org", user.email end def test_update_columns skip if mongoid? user = User.create!(name: "Test", email: "test@example.org") user.update_columns(name: "New", email: "new@example.org") assert_equal "New", user.name assert_equal "new@example.org", user.email user = User.last assert_equal "New", user.name assert_equal "new@example.org", user.email end def test_update_attribute user = User.create!(name: "Test", email: "test@example.org") user.update_attribute(:name, "New") assert_equal "New", user.name user.update_attribute(:email, "new@example.org") assert_equal "new@example.org", user.email user = User.last assert_equal "New", user.name assert_equal "new@example.org", user.email end def test_write_attribute skip if mongoid? user = User.create!(email: "test@example.org") user.write_attribute(:email, "new@example.org") user.save! assert_equal "new@example.org", User.last.email end def test_nil user = User.create!(email: "test@example.org") user.email = nil assert_nil user.email_ciphertext end def test_empty_string user = User.create!(email: "test@example.org") user.email = "" assert_equal "", user.email_ciphertext end def test_attribute_present user = User.create!(name: "Test", email: "test@example.org") assert user.name? assert user.email? user = User.last assert user.name? assert user.email? user2 = User.create!(name: "", email: "") assert !user2.name? assert !user2.email? end def test_hybrid phone = "555-555-5555" User.create!(phone: phone) user = User.last assert_equal phone, user.phone end def test_hybrid_no_decryption_key Agent.delete_all agent = Agent.create!(email: "test@example.org") original_email_ciphertext = agent.email_ciphertext assert_equal agent, Agent.last agent = Agent.last agent.name = "Test" agent.save! agent = Agent.last assert_equal original_email_ciphertext, agent.email_ciphertext agent = Agent.last agent.email = "new@example.org" agent.save! agent = Agent.last assert agent.inspect assert_nil agent.attributes["email"] assert agent.attributes["email_ciphertext"] # TODO change to Lockbox::DecryptionError? error = assert_raises(ArgumentError) do agent.email end assert_equal "No decryption key set", error.message end def test_hybrid_no_decryption_key_proc Agent.delete_all agent = Agent.create!(personal_email: "test@example.org") original_email_ciphertext = agent.personal_email_ciphertext assert_equal agent, Agent.last agent = Agent.last agent.name = "Test" agent.save! agent = Agent.last assert_equal original_email_ciphertext, agent.personal_email_ciphertext agent = Agent.last agent.personal_email = "new@example.org" agent.save! agent = Agent.last assert agent.inspect assert_nil agent.attributes["personal_email"] assert agent.attributes["personal_email_ciphertext"] # TODO change to Lockbox::DecryptionError? error = assert_raises(ArgumentError) do agent.personal_email end assert_equal "No decryption key set", error.message end def test_validations_valid post = Post.new(title: "Hello World") assert post.valid? post.save! post = Post.last assert post.valid? end def test_validations_presence post = Post.new assert !post.valid? assert_equal "Title can't be blank", post.errors.full_messages.first end def test_validations_length post = Post.new(title: "Hi") assert !post.valid? assert_equal "Title is too short (minimum is 3 characters)", post.errors.full_messages.first end def test_encode skip "Can't get Mongoid to handle binary data" if mongoid? ssn = "123-45-6789" User.create!(ssn: ssn) user = User.last assert_equal user.ssn, ssn nonce_size = 12 auth_tag_size = 16 assert_equal nonce_size + ssn.bytesize + auth_tag_size, user.ssn_ciphertext.bytesize end def test_associated_data user = User.create!(name: "Test", region: "Data") assert_equal "Data", User.last.region user.update!(name: "New") assert_raises(Lockbox::DecryptionError) do User.last.region end end def test_attribute_key_encrypted_column email = "test@example.org" user = User.create!(email: email) key = Lockbox.attribute_key(table: "users", attribute: "email_ciphertext") box = Lockbox.new(key: key, encode: true) assert_equal email, box.decrypt(user.email_ciphertext) end # TODO prefer encrypt_email def test_generate_attribute_ciphertext email = "test@example.org" ciphertext = User.generate_email_ciphertext(email) key = Lockbox.attribute_key(table: "users", attribute: "email_ciphertext") box = Lockbox.new(key: key, encode: true) assert_equal email, box.decrypt(ciphertext) end # TODO prefer decrypt_email def test_decrypt_attribute_ciphertext email = "test@example.org" user = User.create!(email: email) assert_equal email, User.decrypt_email_ciphertext(user.email_ciphertext) end def test_padding user = User.create!(city: "New York") assert_equal 12 + 16 + 16, Base64.decode64(user.city_ciphertext).bytesize end def test_padding_empty_string user = User.create!(city: "") assert_equal 12 + 16 + 16, Base64.decode64(user.city_ciphertext).bytesize end def test_padding_invalid user = User.create!(city_ciphertext: "") assert_raises(Lockbox::DecryptionError) do user.city end end def test_bad_master_key with_master_key("bad") do assert_raises(Lockbox::Error) do User.create!(email: "test@example.org") end end end def test_restore_reset original_name = "Test" original_email = "test@example.org" new_name = "New" new_email = "new@example.org" user = User.create!(name: original_name, email: original_email) user.name = new_name user.email = new_email if mongoid? assert_equal original_name, user.reset_name! assert_equal original_email, user.reset_email! else user.restore_name! user.restore_email! end assert_equal original_name, user.name assert_equal original_email, user.email end def test_reset_to_default skip unless mongoid? original_name = "Test" original_email = "test@example.org" new_name = "New" new_email = "new@example.org" user = User.create!(name: original_name, email: original_email) user.name = new_name user.email = new_email assert_nil user.reset_name_to_default! assert_nil user.reset_email_to_default! assert_nil user.name assert_nil user.email end def test_plaintext_not_saved skip unless mongoid? user = User.create!(email: "test@example.org") assert_no_plaintext_attributes user = User.last user.email = "new@example.org" user.save! assert_no_plaintext_attributes user = User.last user.email user.email = "new2@example.org" user.save! assert_no_plaintext_attributes end def test_unencrypted_column if mongoid? assert_output(nil, /WARNING: Unencrypted field with same name: state/) do User.create!(state: "CA") end # not currently saved, but this is not guaranteed assert_equal ["_id", "state_ciphertext"], User.collection.find.first.keys else assert_output(nil, /WARNING: Unencrypted column with same name: state/) do User.create!(state: "CA") end # currently saved result = User.connection.select_all("SELECT state FROM users").to_a assert_equal [{"state" => "CA"}], result end end def test_callable_options email = "test@example.org" admin = Admin.create!(other_email: email) box = Lockbox.new(key: "2"*64, encode: true) assert_equal email, box.decrypt(admin.other_email_ciphertext) end def test_callable_options_record email = "test@example.org" admin = Admin.create!(personal_email: email) box = Lockbox.new(key: admin.record_key, encode: true) assert_equal email, box.decrypt(admin.personal_email_ciphertext) end def test_symbol_options email = "test@example.org" admin = Admin.create!(email: email) box = Lockbox.new(key: admin.record_key, encode: true) assert_equal email, box.decrypt(admin.email_ciphertext) end def test_key_table_key_attribute email = "test@example.org" admin = Admin.create!(email_address: email) assert_equal email, User.decrypt_email_ciphertext(admin.email_address_ciphertext) end def test_previous_versions_key email = "test@example.org" key = User.lockbox_attributes[:email][:previous_versions][0].fetch(:key) box = Lockbox.new(key: key, encode: true) User.create!(email_ciphertext: box.encrypt(email)) assert_equal email, User.last.email end def test_previous_versions_master_key email = "test@example.org" master_key = User.lockbox_attributes[:email][:previous_versions][1].fetch(:master_key) key = Lockbox.attribute_key(table: "users", attribute: "email_ciphertext", master_key: master_key) box = Lockbox.new(key: key, encode: true) User.create!(email_ciphertext: box.encrypt(email)) assert_equal email, User.last.email end def test_previous_versions_key_table_key_attribute email = "test@example.org" key = Lockbox.attribute_key(table: "people", attribute: "email_ciphertext") box = Lockbox.new(key: key, encode: true) admin = Admin.create!(email_address_ciphertext: box.encrypt(email)) assert_equal email, Admin.decrypt_email_address_ciphertext(admin.email_address_ciphertext) end def test_encrypted_attribute email = "test@example.org" admin = Admin.create!(work_email: email) assert admin.encrypted_email end def test_encrypted_attribute_duplicate error = assert_raises do Admin.has_encrypted :dup_email, encrypted_attribute: "encrypted_email" end assert_equal "Multiple encrypted attributes use the same column: encrypted_email", error.message end # uses key from encrypted attribute def test_encrypted_attribute_key email = "test@example.org" admin = Admin.create!(work_email: email) key = Lockbox.attribute_key(table: "admins", attribute: "encrypted_email") box = Lockbox.new(key: key, encode: true) assert_equal email, box.decrypt(admin.encrypted_email) end def test_encrypts_no_attributes error = assert_raises(ArgumentError) do Admin.has_encrypted end assert_equal "No attributes specified", error.message end private def assert_no_plaintext_attributes Guard.all.each do |user| bad_keys = user.attributes.keys & %w(email phone ssn) assert_equal [], bad_keys, "Plaintext attribute exists" end end end ankane-lockbox-c8a7a5f/test/model_types_test.rb000066400000000000000000000463461516427617200220250ustar00rootroot00000000000000require_relative "test_helper" class ModelTypesTest < Minitest::Test def setup skip if mongoid? end def test_string assert_attribute :country, "USA", format: "USA" end def test_string_utf8 assert_attribute :country, "Łukasz", format: "Łukasz" end def test_string_non_utf8 if postgresql? || mysql? error = assert_raises(ActiveRecord::StatementInvalid) do assert_attribute :country, "Hi \255", format: "Hi \255" end if postgresql? assert_includes error.message, "PG::CharacterNotInRepertoire" else assert_includes error.message, "Incorrect string value" end else assert_attribute :country, "Hi \255", format: "Hi \255" end end def test_boolean_true assert_attribute :active, true, format: "t" end def test_boolean_false assert_attribute :active, false, format: "f" end def test_boolean_bytesize assert_bytesize :active, true, false, size: 1 end def test_boolean_invalid # non-falsey values are true assert_attribute :active, "invalid", expected: true end def test_boolean_empty_string assert_attribute :active, "", expected: nil end def test_boolean_query_attribute user = User.create!(active: true, active2: true) assert user.active? assert user.active2? user = User.last assert user.active? assert user.active2? user = User.create!(active: false, active2: false) refute user.active? refute user.active2? user = User.last refute user.active? refute user.active2? end def test_date born_on = Date.current assert_attribute :born_on, born_on, format: born_on.strftime("%Y-%m-%d") end def test_date_bytesize assert_bytesize :born_on, Date.current, Date.current + 10000, size: 10 assert_bytesize :born_on, Date.current, Date.current - 10000, size: 10 assert_bytesize :born_on, Date.current, Date.parse("999-01-01"), size: 10 refute_bytesize :born_on, Date.current, Date.parse("99999-01-01") end def test_date_invalid assert_attribute :born_on, "invalid", expected: nil end def test_datetime skip if mysql? signed_at = Time.current.round(6) assert_attribute :signed_at, signed_at, format: signed_at.utc.iso8601(9), time_zone: true end def test_datetime_bytesize assert_bytesize :signed_at, Time.current, Time.current + 100.years, size: 30 assert_bytesize :signed_at, Time.current, Time.current - 100.years, size: 30 end def test_datetime_invalid assert_attribute :signed_at, "invalid", expected: nil end def test_time skip if mysql? opens_at = if ActiveRecord::VERSION::STRING.to_f >= 8.1 Time.current.round(6).change(year: 2000, month: 1, day: 1) else Time.current.round(6).utc.change(year: 2000, month: 1, day: 1) end assert_attribute :opens_at, opens_at, format: opens_at.utc.strftime("%H:%M:%S.%N") end def test_time_bytesize assert_bytesize :opens_at, Time.current, Time.current + 5.minutes, size: 18 end def test_time_invalid assert_attribute :opens_at, "invalid", expected: nil end def test_integer sign_in_count = 10 assert_attribute :sign_in_count, sign_in_count, format: [sign_in_count].pack("q>") end def test_integer_negative sign_in_count = -10 assert_attribute :sign_in_count, sign_in_count, format: [sign_in_count].pack("q>") end def test_integer_bytesize assert_bytesize :sign_in_count, 10, 1_000_000_000, size: 8 assert_bytesize :sign_in_count, -1_000_000_000, 1_000_000_000, size: 8 end def test_integer_invalid assert_attribute :sign_in_count, "invalid", expected: 0 assert_attribute :sign_in_count, "55invalid", expected: 55 end def test_integer_in_range value = 2**63 - 1 assert_attribute :sign_in_count, value, expected: value value = -(2**63) assert_attribute :sign_in_count, value, expected: value end def test_integer_out_of_range assert_raises ActiveModel::RangeError do User.create!(sign_in_count: 2**63) end assert_raises ActiveModel::RangeError do User.create!(sign_in_count2: 2**63) end assert_raises ActiveModel::RangeError do User.create!(sign_in_count: -(2**63 + 1)) end assert_raises ActiveModel::RangeError do User.create!(sign_in_count2: -(2**63 + 1)) end end def test_integer_query_attribute user = User.create!(sign_in_count: 1, sign_in_count2: 1) assert user.sign_in_count? assert user.sign_in_count2? user = User.last assert user.sign_in_count? assert user.sign_in_count2? user = User.create!(sign_in_count: 0, sign_in_count2: 0) refute user.sign_in_count? refute user.sign_in_count2? user = User.last refute user.sign_in_count? refute user.sign_in_count2? end def test_float skip if mysql? latitude = 10.12345678 assert_attribute :latitude, latitude, format: [latitude].pack("G") end def test_float_negative skip if mysql? latitude = -10.12345678 assert_attribute :latitude, latitude, format: [latitude].pack("G") end def test_float_bigdecimal skip if postgresql? || mysql? latitude = BigDecimal("123456789.123456789123456789") assert_attribute :latitude, latitude, expected: latitude.to_f, format: [latitude].pack("G") end def test_float_bytesize assert_bytesize :latitude, 10, 1_000_000_000.123, size: 8 assert_bytesize :latitude, -1_000_000_000.123, 1_000_000_000.123, size: 8 end def test_float_invalid assert_attribute :latitude, "invalid", expected: 0.0 assert_attribute :latitude, "1.2invalid", expected: 1.2 end def test_float_infinity skip if mysql? assert_attribute :latitude, Float::INFINITY, expected: Float::INFINITY, format: [Float::INFINITY].pack("G") assert_attribute :latitude, -Float::INFINITY, expected: -Float::INFINITY, format: [-Float::INFINITY].pack("G") end def test_float_nan skip if mysql? assert_attribute :latitude, Float::NAN, expected: Float::NAN, format: [Float::NAN].pack("G") end def test_type_decimal longitude = BigDecimal("123456789.123456789123456789") assert_attribute :longitude, longitude, format: "123456789.123456789123456789" end def test_type_decimal_integer longitude = BigDecimal("123") assert_attribute :longitude, longitude, format: "123.0" end def test_type_decimal_trailing_zeros longitude = BigDecimal("123.00000") assert_attribute :longitude, longitude, format: "123.0" end def test_type_decimal_bytesize assert_bytesize :longitude, 0.1, 0.2, size: 3 assert_bytesize :longitude, 0.11, 0.22, size: 4 end def test_type_decimal_invalid assert_attribute :longitude, "invalid", expected: BigDecimal("0.0") assert_attribute :longitude, "1.2invalid", expected: BigDecimal("1.2") end def test_type_decimal_infinity skip "Infinity not supported" if mysql? assert_attribute :longitude, BigDecimal("Infinity"), expected: BigDecimal("Infinity"), format: "Infinity" assert_attribute :longitude, BigDecimal("+Infinity"), expected: BigDecimal("Infinity"), format: "Infinity" assert_attribute :longitude, Float::INFINITY, expected: BigDecimal("Infinity"), format: "Infinity" assert_attribute :longitude, BigDecimal("-Infinity"), expected: BigDecimal("-Infinity"), format: "-Infinity" assert_attribute :longitude, -Float::INFINITY, expected: BigDecimal("-Infinity"), format: "-Infinity" end def test_type_decimal_nan skip "NaN not supported" if mysql? assert_attribute :longitude, BigDecimal("NaN"), expected: BigDecimal("NaN"), format: "NaN" assert_attribute :longitude, Float::NAN, expected: BigDecimal("NaN"), format: "NaN" end def test_binary video = SecureRandom.random_bytes(512) assert_attribute :video, video, format: video end def test_binary_bytesize refute_bytesize :video, SecureRandom.random_bytes(15), SecureRandom.random_bytes(16) end def test_json skip if mysql? data = {a: 1, b: "hi"}.as_json assert_attribute :data, data, format: data.to_json user = User.last new_data = {c: Time.now}.as_json user.data = new_data assert_equal [data, new_data], user.changes["data"] user.data2 = new_data assert_equal [data, new_data], user.changes["data2"] end def test_json_in_place user = User.create!(data2: {a: 1, b: "hi"}) user.data2[:c] = "world" user.save! user = User.last assert_equal "world", user.data2["c"] end def test_json_in_place_callbacks Person.create!(data: {"count" => 0}) person = Person.last assert_equal 1, person.data["count"] person.save! person = Person.last assert_equal 2, person.data["count"] end def test_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 end def test_hash info = {a: 1, b: "hi"} assert_attribute :info, info, format: info.to_yaml # TODO see why keys are strings instead of symbols user = User.last new_info = {c: Time.now} user.info = new_info if ActiveRecord::VERSION::STRING.to_f >= 8.1 assert_equal [info, new_info], user.changes["info"] else assert_equal [info.stringify_keys, new_info.stringify_keys], user.changes["info"] end user.info2 = new_info if ActiveRecord::VERSION::STRING.to_f >= 8.1 assert_equal [info, new_info], user.changes["info2"] else assert_equal [info.stringify_keys, new_info.stringify_keys], user.changes["info2"] end end def test_hash_invalid assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(info: "invalid") end assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(info2: "invalid") end end def test_hash_in_place user = User.create!(info2: {a: 1, b: "hi"}) user.info2[:c] = "world" user.save! user = User.last assert_equal "world", user.info2[:c] end def test_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 end def test_hash_empty user = User.create! assert_equal({}, user.info) assert_equal({}, user.info2) end def test_array coordinates = [1, 2, 3] assert_attribute :coordinates, coordinates, format: coordinates.to_yaml user = User.last new_coordinates = [1, 2, 3, 4, 5] user.coordinates = new_coordinates assert_equal [coordinates, new_coordinates], user.changes["coordinates"] user.coordinates2 = new_coordinates assert_equal [coordinates, new_coordinates], user.changes["coordinates2"] end def test_array_invalid assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(coordinates: "invalid") end assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(coordinates2: "invalid") end end def test_array_in_place user = User.create!(coordinates2: [1, 2, 3]) user.coordinates2[3] = 4 user.save! user = User.last assert_equal 4, user.coordinates2[3] end def test_array_save_twice coordinates2 = [1, 2, 3] user = User.create!(coordinates2: coordinates2) user.reload user.save! user.coordinates2 user.save! assert_equal coordinates2, user.coordinates2 end def test_array_empty user = User.create! assert_equal [], user.coordinates assert_equal [], user.coordinates2 end def test_serialize_json properties = {a: 1, b: "hi"}.as_json assert_attribute :properties, properties, format: properties.to_json user = User.last new_properties = {c: Time.now}.as_json user.properties = new_properties assert_equal [properties, new_properties], user.changes["properties"] user.properties2 = new_properties assert_equal [properties, new_properties], user.changes["properties2"] end def test_serialize_json_in_place user = User.create!(properties2: {a: 1, b: "hi"}) user.properties2[:c] = "world" user.save! user = User.last assert_equal "world", user.properties2["c"] end def test_serialize_hash settings = {a: 1, b: "hi"} assert_attribute :settings, settings, format: settings.to_yaml # TODO see why changes keys are strings instead of symbols user = User.last new_settings = {c: Time.now} user.settings = new_settings if ActiveRecord::VERSION::STRING.to_f >= 8.1 assert_equal [settings, new_settings], user.changes["settings"] else assert_equal [settings.stringify_keys, new_settings.stringify_keys], user.changes["settings"] end user.settings2 = new_settings if ActiveRecord::VERSION::STRING.to_f >= 8.1 assert_equal [settings, new_settings], user.changes["settings2"] else assert_equal [settings.stringify_keys, new_settings.stringify_keys], user.changes["settings2"] end end def test_serialize_hash_in_place user = User.create!(settings2: {a: 1, b: "hi"}) user.settings2[:c] = "world" user.save! user = User.last assert_equal "world", user.settings2[:c] end def test_serialize_hash_in_place_update User.create!(settings2: {a: 1, b: "hi"}) user = User.last user.settings2[:b] = "hello" assert_equal "hello", user.settings2[:b] end def test_serialize_hash_invalid assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(settings: "invalid") end assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(settings2: "invalid") end end def test_serialize_array messages = [1, 2, 3] assert_attribute :messages, messages, format: messages.to_yaml user = User.last new_messages = [4] user.messages = new_messages assert_equal [messages, new_messages], user.changes["messages"] user.messages2 = new_messages assert_equal [messages, new_messages], user.changes["messages2"] end def test_serialize_array_in_place user = User.create!(messages2: [1, 2, 3]) user.messages2[3] = 4 user.save! user = User.last assert_equal 4, user.messages2[3] end def test_serialize_array_invalid assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(messages: "invalid") end assert_raises ActiveRecord::SerializationTypeMismatch do User.create!(messages2: "invalid") end end def test_inet_ipv4 skip unless inet_supported? ip = IPAddr.new("127.0.0.1") assert_attribute :ip, ip, expected: ip, format: [0, 32, ip.hton, "\x00"*12].pack("cca4a12") assert_attribute :ip, ip.to_s, expected: ip, format: [0, 32, ip.hton, "\x00"*12].pack("cca4a12") end def test_inet_ipv4_prefix skip unless inet_supported? ip = IPAddr.new("127.0.0.0/24") assert_attribute :ip, ip, expected: ip, format: [0, 24, ip.hton, "\x00"*12].pack("cca4a12") assert_attribute :ip, "127.0.0.0/24", expected: ip, format: [0, 24, ip.hton, "\x00"*12].pack("cca4a12") end def test_inet_ipv6 skip unless inet_supported? ip = IPAddr.new("::") assert_attribute :ip, ip, expected: ip, format: [1, 128, ip.hton].pack("cca16") assert_attribute :ip, ip.to_s, expected: ip, format: [1, 128, ip.hton].pack("cca16") end def test_inet_bytesize skip unless inet_supported? assert_bytesize :ip, "127.0.0.1", "255.255.255.255", size: 18 assert_bytesize :ip, "::", "2606:4700:4700::64", size: 18 assert_bytesize :ip, "127.0.0.0/24", "255.255.255.255", size: 18 end def test_inet_invalid skip unless inet_supported? assert_attribute :ip, "invalid", expected: nil end def test_store credentials = {a: 1, b: "hi"}.as_json assert_attribute :credentials, credentials, format: credentials.to_json assert_attribute :username, "hello", check_nil: false end def test_store_attributes assert_output(nil, /WARNING: encrypting store accessors is not supported/) do User.has_encrypted :username3 end end def test_custom assert_attribute :configuration, "USA", format: "USA!!" end def test_custom_attribute assert_attribute :config, "USA", format: "USA!!" end def test_migrating user = User.create!(conf: "Hi") key = Lockbox.attribute_key(table: "users", attribute: "conf_ciphertext") box = Lockbox.new(key: key, encode: true) assert_equal "Hi!!", box.decrypt_str(user.conf_ciphertext) end private def assert_attribute(attribute, value, format: nil, time_zone: false, check_nil: true, **options) attribute2 = "#{attribute}2".to_sym encrypted_attribute = "#{attribute2}_ciphertext" expected = options.key?(:expected) ? options[:expected] : value user = User.create!(attribute => value, attribute2 => value) assert_equal expected, user.send(attribute) assert_equal expected, user.send(attribute2) assert_nil user.send(encrypted_attribute) if expected.nil? # encoding assert_encoding expected, user, attribute, attribute2 # time zone if time_zone assert_equal Time.zone, user.send(attribute).time_zone assert_equal Time.zone, user.send(attribute2).time_zone end user = User.last # SQLite does not support NaN and only stores 15 digits for decimal columns assert_equal expected, user.send(attribute) unless (expected.try(:nan?) || expected.is_a?(BigDecimal)) && !ENV["ADAPTER"] assert_equal expected, user.send(attribute2) # encoding assert_encoding expected, user, attribute, attribute2 # time zone if time_zone assert_equal Time.zone, user.send(attribute).time_zone assert_equal Time.zone, user.send(attribute2).time_zone end if format key = Lockbox.attribute_key(table: "users", attribute: encrypted_attribute) box = Lockbox.new(key: key, encode: true) assert_equal format.b, box.decrypt(user.send(encrypted_attribute)) end if check_nil user.send("#{attribute2}=", nil) assert_nil user.send(encrypted_attribute) end end def assert_equal(exp, act) if exp.try(:nan?) assert act.try(:nan?), "Expected NaN" elsif exp.nil? assert_nil act else super end end def assert_bytesize(*args, size: nil) sizes = bytesizes(*args) assert_equal(*sizes) assert_equal size, sizes[0] - 12 - 16 if size end def refute_bytesize(*args) refute_equal(*bytesizes(*args)) end def bytesizes(attribute, value1, value2) attribute = "#{attribute}2".to_sym encrypted_attribute = "#{attribute}_ciphertext" user1 = User.create!(attribute => value1) user2 = User.create!(attribute => value2) result1 = Base64.decode64(user1.send(encrypted_attribute)).bytesize result2 = Base64.decode64(user2.send(encrypted_attribute)).bytesize [result1, result2] end def assert_encoding(expected, user, attribute, attribute2) if expected.is_a?(String) assert_equal expected.encoding, user.send(attribute).encoding assert_equal expected.encoding, user.send(attribute2).encoding elsif expected.is_a?(Hash) k = expected.key?("b") ? "b" : :b assert_equal expected[k].encoding, user.send(attribute)[k].encoding assert_equal expected[k].encoding, user.send(attribute2)[k].encoding end end def mysql? ["mysql2", "trilogy"].include?(ENV["ADAPTER"]) end def postgresql? ENV["ADAPTER"] == "postgresql" end def inet_supported? postgresql? end end ankane-lockbox-c8a7a5f/test/pluck_test.rb000066400000000000000000000051761516427617200206130ustar00rootroot00000000000000require_relative "test_helper" class PluckTest < Minitest::Test def setup skip if mongoid? User.delete_all Admin.delete_all Robot.delete_all end def test_symbol User.create!(name: "Test 1", email: "test1@example.org") User.create!(name: "Test 2", email: "test2@example.org") # unencrypted assert_equal ["Test 1", "Test 2"], User.order(:id).pluck(:name) assert_equal ["Test 1", "Test 2"], User.order(:id).pluck(:id, :name).map(&:last) # encrypted assert_equal ["test1@example.org", "test2@example.org"], User.order(:id).pluck(:email) assert_equal ["test1@example.org", "test2@example.org"], User.order(:id).pluck(:id, :email).map(&:last) # multiple assert_equal [["Test 1", "test1@example.org"], ["Test 2", "test2@example.org"]], User.order(:id).pluck(:name, :email) # where assert_equal ["test2@example.org"], User.where(name: "Test 2").pluck(:email) end def test_string User.create!(name: "Test 1", email: "test1@example.org") User.create!(name: "Test 2", email: "test2@example.org") # unencrypted assert_equal ["Test 1", "Test 2"], User.order(:id).pluck("name") assert_equal ["Test 1", "Test 2"], User.order(:id).pluck("id", "name").map(&:last) # encrypted assert_equal ["test1@example.org", "test2@example.org"], User.order(:id).pluck("email") assert_equal ["test1@example.org", "test2@example.org"], User.order(:id).pluck("id", "email").map(&:last) # multiple assert_equal [["Test 1", "test1@example.org"], ["Test 2", "test2@example.org"]], User.order(:id).pluck("name", "email") # where assert_equal ["test2@example.org"], User.where(name: "Test 2").pluck("email") end def test_object User.create! assert_equal ["Test"], User.pluck(Arel::Nodes::Quoted.new("Test")) end def test_callable_options Admin.create!(other_email: "test@example.org") assert_equal ["test@example.org"], Admin.pluck(:other_email) end def test_callable_options_record Admin.create!(personal_email: "test@example.org") error = assert_raises(NameError) do Admin.pluck(:personal_email) end assert_match(/undefined local variable or method ['`]record_key'/, error.message) end def test_symbol_options Admin.create!(email: "test@example.org") error = assert_raises(Lockbox::Error) do Admin.pluck(:email) end assert_equal "Not available since :key depends on record", error.message end def test_migrating Robot.create!(name: "Test 1") Robot.create!(name: "Test 2") Robot.update_all(name_ciphertext: nil) assert_equal ["Test 1", "Test 2"], Robot.order(:id).pluck(:name) end end ankane-lockbox-c8a7a5f/test/rotate_test.rb000066400000000000000000000037301516427617200207650ustar00rootroot00000000000000require_relative "test_helper" class RotateTest < Minitest::Test def setup User.delete_all end def test_rotate 10.times do |i| User.create!(city: "City #{i}", email: "test#{i}@example.org") end user = User.last original_city_ciphertext = user.city_ciphertext original_email_ciphertext = user.email_ciphertext Lockbox.rotate(User, attributes: [:email], batch_size: 5) user = User.last assert_equal "City 9", user.city assert_equal "test9@example.org", user.email assert_equal original_city_ciphertext, user.city_ciphertext refute_equal original_email_ciphertext, user.email_ciphertext end def test_rotate_relation users = 2.times.map { |i| User.create!(email: "test#{i}@example.org") } original_ciphertexts = users.map(&:email_ciphertext) Lockbox.rotate(User.where(id: users.last.id), attributes: [:email]) new_ciphertexts = users.map(&:reload).map(&:email_ciphertext) assert_equal original_ciphertexts.first, new_ciphertexts.first refute_equal original_ciphertexts.last, new_ciphertexts.last end def test_rotate_bad_attribute error = assert_raises(ArgumentError) do Lockbox.rotate(User, attributes: [:bad]) end assert_equal "Bad attribute: bad", error.message end def test_rotation email = "test@example.org" key = User.lockbox_attributes[:email][:previous_versions].first[:key] box = Lockbox.new(key: key, encode: true) user = User.create!(email_ciphertext: box.encrypt(email)) user = User.last assert_equal email, user.email end def test_rotation_master_key email = "test@example.org" master_key = User.lockbox_attributes[:email][:previous_versions].last[:master_key] key = Lockbox.attribute_key(table: "users", attribute: "email_ciphertext", master_key: master_key) box = Lockbox.new(key: key, encode: true) user = User.create!(email_ciphertext: box.encrypt(email)) user = User.last assert_equal email, user.email end end ankane-lockbox-c8a7a5f/test/shrine_test.rb000066400000000000000000000020121516427617200207470ustar00rootroot00000000000000require_relative "test_helper" class ShrineTest < Minitest::Test def setup @image_file = nil end def test_works lockbox = Lockbox.new(key: Lockbox.generate_key) uploaded_file = PhotoUploader.upload(lockbox.encrypt_io(image_file), :store) data = lockbox.decrypt(uploaded_file.read) assert_equal image_content, data assert_equal "image/png", Shrine.mime_type(StringIO.new(data)) end def test_model lockbox = Lockbox.new(key: Lockbox.generate_key) user = User.create!(photo: lockbox.encrypt_io(image_file)) data = lockbox.decrypt(user.photo.read) assert_equal image_content, data assert_equal "image/png", Shrine.mime_type(StringIO.new(data)) user = User.last data = lockbox.decrypt(user.photo.read) assert_equal image_content, data assert_equal "image/png", Shrine.mime_type(StringIO.new(data)) end def image_content File.binread("test/support/image.png") end def image_file @image_file ||= File.open("test/support/image.png", "rb") end end ankane-lockbox-c8a7a5f/test/support/000077500000000000000000000000001516427617200176145ustar00rootroot00000000000000ankane-lockbox-c8a7a5f/test/support/active_record.rb000066400000000000000000000105061516427617200227540ustar00rootroot00000000000000require "active_record" ActiveRecord::Base.logger = $logger class User < ActiveRecord::Base class Configuration < ActiveModel::Type::String def serialize(value) "#{value}!!" end def deserialize(value) return if value.nil? value[0..-3].force_encoding(Encoding::UTF_8) end end if respond_to?(:has_one_attached) has_one_attached :avatar encrypts_attached :avatar has_many_attached :avatars encrypts_attached :avatars has_one_attached :image has_many_attached :images end mount_uploader :document, DocumentUploader mount_uploaders :documents, DocumentUploader serialize :documents, coder: JSON has_encrypted :email, previous_versions: [{key: Lockbox.generate_key}, {master_key: Lockbox.generate_key}] key_pair = Lockbox.generate_key_pair has_encrypted :phone, algorithm: "hybrid", encryption_key: key_pair[:encryption_key], decryption_key: key_pair[:decryption_key] serialize :properties, coder: JSON serialize :properties2, coder: JSON serialize :settings, type: Hash, coder: YAML serialize :settings2, type: Hash, coder: YAML serialize :messages, type: Array, coder: YAML serialize :messages2, type: Array, coder: YAML has_encrypted :properties2, :settings2, :messages2 serialize :info, type: Hash, coder: YAML serialize :coordinates, type: Array, coder: YAML store :credentials, accessors: [:username], coder: JSON store :credentials2, accessors: [:username2], coder: JSON has_encrypted :credentials2 store :credentials3, accessors: [:username3], coder: JSON attribute :configuration, Configuration.new has_encrypted :configuration2, type: Configuration.new attribute :config, Configuration.new attribute :config2, Configuration.new has_encrypted :config2 attribute :conf, Configuration.new has_encrypted :conf, migrating: true has_encrypted :country2, type: :string has_encrypted :active2, type: :boolean has_encrypted :born_on2, type: :date has_encrypted :signed_at2, type: :datetime has_encrypted :opens_at2, type: :time has_encrypted :sign_in_count2, type: :integer has_encrypted :latitude2, type: :float has_encrypted :longitude2, type: :decimal has_encrypted :video2, type: :binary has_encrypted :data2, type: :json has_encrypted :info2, type: :hash has_encrypted :coordinates2, type: :array if ENV["ADAPTER"] == "postgresql" has_encrypted :ip2, type: :inet end has_encrypted :city, padding: true has_encrypted :ssn, encode: false has_encrypted :region, associated_data: -> { name } has_encrypted :state has_rich_text :content if respond_to?(:has_rich_text) include PhotoUploader::Attachment(:photo) end class Post < ActiveRecord::Base has_encrypted :title validates :title, presence: true, length: {minimum: 3} if respond_to?(:has_one_attached) has_one_attached :photo end end class Robot < ActiveRecord::Base default_scope { order(:id) } serialize :properties, coder: JSON has_encrypted :name, :email, :properties, migrating: true end class Comment < ActiveRecord::Base if respond_to?(:has_one_attached) has_one_attached :image has_many_attached :images end # not a field, but add lockbox_attachments to model encrypts_attached :hack end class Admin < ActiveRecord::Base has_encrypted :email, key: :record_key has_encrypted :personal_email, key: -> { record_key } has_encrypted :other_email, key: -> { "2"*64 } def record_key "1"*64 end has_encrypted :email_address, key_table: "users", key_attribute: "email_ciphertext", previous_versions: [{key_table: "people", key_attribute: "email_ciphertext"}] has_encrypted :work_email, encrypted_attribute: "encrypted_email" attribute :code, :string, default: -> { "hello" } end class Agent < ActiveRecord::Base key_pair = Lockbox.generate_key_pair has_encrypted :email, algorithm: "hybrid", encryption_key: key_pair[:encryption_key] has_encrypted :personal_email, algorithm: "hybrid", encryption_key: key_pair[:encryption_key], decryption_key: -> { nil } end class Person < ActiveRecord::Base has_encrypted :data, type: :json before_save :update_data def update_data data["count"] += 1 end end # ensure has_encrypted does not cause model schema to load if [User, Post, Robot, Comment, Admin, Agent, Person].any? { |v| v.send(:schema_loaded?) } raise "has_encrypted loading model schema early" end ankane-lockbox-c8a7a5f/test/support/carrierwave.rb000066400000000000000000000013011516427617200224460ustar00rootroot00000000000000CarrierWave.configure do |config| config.storage = :file config.store_dir = "/tmp/store" config.cache_dir = "/tmp/cache" end class TextUploader < CarrierWave::Uploader::Base encrypt process append: "!!" version :thumb do process append: ".." end def append(str) File.write(current_path, File.read(current_path) + str) end end class AvatarUploader < CarrierWave::Uploader::Base encrypt end class DocumentUploader < CarrierWave::Uploader::Base encrypt end class ImageUploader < CarrierWave::Uploader::Base process append: "!!" version :thumb do process append: ".." end def append(str) File.write(current_path, File.read(current_path) + str) end end ankane-lockbox-c8a7a5f/test/support/combustion.rb000066400000000000000000000007421516427617200223260ustar00rootroot00000000000000Combustion.path = "test/internal" components = [:active_record, :active_job, :active_storage, :action_text] Lockbox.encrypts_action_text_body Combustion.initialize!(*components) do config.load_defaults Rails.version.to_f config.logger = $logger config.time_zone = "Mountain Time (US & Canada)" config.active_job.queue_adapter = :inline config.active_storage.service = :test # TODO remove config.active_record.yaml_column_permitted_classes = [Symbol, Time] end ankane-lockbox-c8a7a5f/test/support/doc.pdf000066400000000000000000000114131516427617200210540ustar00rootroot00000000000000%PDF-1.3 1 0 obj << /Pages 2 0 R /Type /Catalog >> endobj 2 0 obj << /Type /Pages /Kids [ 3 0 R ] /Count 1 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /Im0 8 0 R >> /ProcSet 6 0 R >> /MediaBox [0 0 360 100] /CropBox [0 0 360 100] /Contents 4 0 R /Thumb 11 0 R >> endobj 4 0 obj << /Length 5 0 R >> stream q 360 0 0 100 0 0 cm /Im0 Do Q endstream endobj 5 0 obj 31 endobj 6 0 obj [ /PDF /Text /ImageI ] endobj 7 0 obj << >> endobj 8 0 obj << /Type /XObject /Subtype /Image /Name /Im0 /Filter [ /FlateDecode ] /Width 360 /Height 100 /ColorSpace 10 0 R /BitsPerComponent 8 /Length 9 0 R >> stream xt7A@cRBBJ%֒R ET?*`DAQ)4FzFX{ p zBBH $޸ݝ]?;sٙeD"H$D"H$D"H$D"H$D"H$D"_y=gBWS`voAv?CgQ9&"ΉqNsbD#Ĉ8'F91:g8,'>9`~VəPGo^~.U$q&ѝs frb .TWqqJ/u`{еP#wvWk{jWmdt7`,в6LT[7{fW;~8:OUV-OUɹ~Sx/&eg'68|9$w7%I)OThz ONݙ.ב >D{BLPlӥ`( sdq|ۭOW?5?bR>#78(5 =ݍKn>(';5߮O7ް,_P*6rnf"ts;ZcU|9woƹ'l; OfauF[y7H}RY?SCeyCF31i5G]ٽ=zVw4qִ.-}YK{jd(z<y.v:QnNbV̱l.fIQe?kuW1q{v8.*rܨ݋7E:r0s3>[?\kh\nif /`g"\r+tcu=yhj&~?c:Cp޹<m<:o sO!dG?RaP&vw;lYBq8-3NF:=Otw.0Cv/EmbsBv/bGstPaN h(?.9 NvhO28.,0 ,,Fm֭"6rwt[!s~bƹibs^8XkfM&Lʡqy՜qlc<'XC vpQ866rǹ JC\eDp[H,s'~K/a vl!W:(\81;t+:sKrQj>Nfd.3T `-3f՜{_52=$e3qB}Z҇eɈ^H/B&62g}\θ-AqN0})#(͸O`({[K܇ [8m#9(r%篠i!L ֤&C{ nόrO¿^!b9l-C#'.S_9O$m{W$5<'M/gb8L w)3nJNsx;yg7Aqz6~=4Î5Lx3Js wnl?C;7@ }s B eiJpMu =rn|-yR*·3 IUzYZ tw΂cpfS8Sý\7=7bkjJY_NAd05C ;=k.3wQ<9Kq̲^(w5X>uF?1N]2ѰAȋx&PrFqn8͞M{'LF]7;I}0yJ2_b؁ON)CJ(h?άi&+'y{].kZy"r[r ș}[,OgəLg#I~{'yL;3k-y"!>Z|w/Mg)ᮛULCr2vΌm>o9>^z譶33zPzJְ%:]D :AD"H$D"H$D"H$D"H$}U!q endstream endobj 9 0 obj 2402 endobj 10 0 obj /DeviceGray endobj 11 0 obj << /Filter [ /FlateDecode ] /Width 1 /Height 1 /ColorSpace 10 0 R /BitsPerComponent 8 /Length 12 0 R >> stream xڻ endstream endobj 12 0 obj 9 endobj 13 0 obj << /Length 14 0 R >> stream   !!!"""###$$$%%%&&&'''((()))***+++,,,---...///000111222333444555666777888999:::;;;<<<===>>>???@@@AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKKLLLMMMNNNOOOPPPQQQRRRSSSTTTUUUVVVWWWXXXYYYZZZ[[[\\\]]]^^^___```aaabbbcccdddeeefffggghhhiiijjjkkklllmmmnnnooopppqqqrrrssstttuuuvvvwwwxxxyyyzzz{{{|||}}}~~~ endstream endobj 14 0 obj 768 endobj 15 0 obj << >> endobj 16 0 obj 768 endobj 17 0 obj << /Title <0069006D0061006700650000> /CreationDate (D:20200718040156) /ModDate (D:20200718040156) /Producer (https://imagemagick.org) >> endobj xref 0 18 0000000000 65535 f 0000000010 00000 n 0000000059 00000 n 0000000118 00000 n 0000000300 00000 n 0000000384 00000 n 0000000402 00000 n 0000000440 00000 n 0000000461 00000 n 0000003045 00000 n 0000003065 00000 n 0000003093 00000 n 0000003240 00000 n 0000003258 00000 n 0000004081 00000 n 0000004101 00000 n 0000004123 00000 n 0000004143 00000 n trailer << /Size 18 /Info 17 0 R /Root 1 0 R /ID [ ] >> startxref 4296 %%EOF ankane-lockbox-c8a7a5f/test/support/image.png000066400000000000000000000053511516427617200214100ustar00rootroot00000000000000PNG  IHDRhd;gAMA a cHRMz&u0`:pQ<bKGD̿tIMEgI" IDATxkpWK!BHZp4\X JCh[F3qX:*E)j4 ΔkBn%) 4%\}/{}ws yyy=&Pv)В@K-I $)В@K-I $)В@K-I $)В@K-I $)В@K-I $)В@K-I $)В@K-I $)ВMӴS%)i95Y]iڼp{iڛꈖ$ZhIR%I$ZhIR%I$Z .S3_wU^kI{r8{oμ3̜5q4o_UU}J>93Xv)qh[w_ػW OO (Y]4YFU/ w?ၱ/SC!6^Ob }?@LŎuXso›ЛM;h}Ӡ?$pcAk * {W?s,}: Ww_6A:,1Yi;S}-ulK%M+\=!. 0ݗ\XW9`9R+Pٽt}y&=]/r}!pN Əf#%{}ҵ8}E_ nn1o-}XMQ!.7-#H}C^/Bq`K)P{H`#"P;^_U8|RU !DQ+EJU/ޠWh(uKع5lXhw2yiQ!}c~i5FP/nf ;@ĜE<@'\ )Zlm̛o{0XR[$~i.<!ĹN@wM/pڹ5׀o"W[ݱ8P]]g_7@Wm)f(-˕JLQ uZ8/7|"?oJCunnc{Mނnj,<Ȧ5' 3d7J)3kiߵJ Y~Ag=802MD/D_(ipNB ;m0PF+ŝ_q]ܪa~eT[s0P @f%:sL&@b> )nUT|i 5TמR׭ P(k49IN0wLEFXh[]8w-i-@_[8bϮm@H$ Q&г-bq[T뎉Ns@>;܈@DȄP“&`2t "0zpE|m6Bȩ_WnЀ=wg"5 c@;w -lw@ww/rjt %(~t1.ro "oROY0ȩu͐f:'ƠC0Boj)ݜȏ.Ÿ$%'=M@w\9KnVxۊ}UE~p/4*8rC/s:6qFր4>ߒ,DzPړnI3@SQx&x%m[mT^OF5뗞LJ]Adw-ћX`mD7 rG1 @@ӭUg?do]Qí ^y `%fknnș)C6נh`o* j2p^`\R p>\bL>IKͱL-` jQSCŔ 0t/Y ཽ6̸ϴC6W4[mZL=d输镱w?kHݢ~ ӂ`oob ^Qi}Ѕ@asS* }3gP@$0 B`gW^f>*h\ :)-%St~L;, D=@B7bGNp:uكcѻ7RֆY\le-;|0/oOЍIљӧ 6u*&ϥz˛JGRlk=* <ʚTwM<=Ak%Aų3^G$Iv89ܖxQVO׶Qiy'ŵ)m<x[k>{Oj1/ y&[F5+S[ɩ}s;+׻|[ʆCEmZ)a$ZhIR%I$ZhIR%I$ZhIR%I$ZhIR%I$ZhIR%I$ZhIPI%tEXtdate:create2020-07-18T04:01:05+00:00T%tEXtdate:modify2020-07-18T04:01:05+00:00 /IENDB`ankane-lockbox-c8a7a5f/test/support/mongoid.rb000066400000000000000000000052431516427617200216010ustar00rootroot00000000000000Mongoid.logger = $logger Mongo::Logger.logger = $logger if defined?(Mongo::Logger) Mongoid.configure do |config| config.connect_to "lockbox_test" end class User include Mongoid::Document field :name, type: String field :email_ciphertext, type: String field :phone_ciphertext, type: String field :city_ciphertext, type: String field :ssn_ciphertext, type: BSON::Binary field :region_ciphertext, type: String field :state, type: String field :state_ciphertext, type: String has_encrypted :email, previous_versions: [{key: Lockbox.generate_key}, {master_key: Lockbox.generate_key}] key_pair = Lockbox.generate_key_pair has_encrypted :phone, algorithm: "hybrid", encryption_key: key_pair[:encryption_key], decryption_key: key_pair[:decryption_key] has_encrypted :city, padding: true has_encrypted :ssn, encode: false has_encrypted :region, associated_data: -> { name } has_encrypted :state include PhotoUploader::Attachment(:photo) field :photo_data, type: String end class Guard include Mongoid::Document include Mongoid::Attributes::Dynamic store_in collection: "users" end class Post include Mongoid::Document field :title_ciphertext, type: String has_encrypted :title validates :title, presence: true, length: {minimum: 3} end class Robot include Mongoid::Document field :name, type: String field :email, type: String field :name_ciphertext, type: String field :email_ciphertext, type: String has_encrypted :name, :email, migrating: true end class Admin include Mongoid::Document field :name, type: String field :email_ciphertext, type: String field :personal_email_ciphertext, type: String field :other_email_ciphertext, type: String field :email_address_ciphertext, type: String field :encrypted_email, type: String field :dep_ciphertext, type: String field :dep2_ciphertext, type: String has_encrypted :email, key: :record_key has_encrypted :personal_email, key: -> { record_key } has_encrypted :other_email, key: -> { "2"*64 } def record_key "1"*64 end has_encrypted :email_address, key_table: "users", key_attribute: "email_ciphertext", previous_versions: [{key_table: "people", key_attribute: "email_ciphertext"}] has_encrypted :work_email, encrypted_attribute: "encrypted_email" end class Agent include Mongoid::Document field :name, type: String field :email_ciphertext, type: String field :personal_email_ciphertext, type: String key_pair = Lockbox.generate_key_pair has_encrypted :email, algorithm: "hybrid", encryption_key: key_pair[:encryption_key] has_encrypted :personal_email, algorithm: "hybrid", encryption_key: key_pair[:encryption_key], decryption_key: -> { nil } end ankane-lockbox-c8a7a5f/test/support/shrine.rb000066400000000000000000000005451516427617200214350ustar00rootroot00000000000000require "shrine" require "shrine/storage/file_system" Shrine.storages = { cache: Shrine::Storage::FileSystem.new("/tmp", prefix: "cache"), store: Shrine::Storage::FileSystem.new("/tmp", prefix: "store") } if mongoid? Shrine.plugin :mongoid else Shrine.plugin :activerecord end Shrine.plugin :determine_mime_type class PhotoUploader < Shrine end ankane-lockbox-c8a7a5f/test/test_helper.rb000066400000000000000000000015261516427617200207470ustar00rootroot00000000000000require "bundler/setup" require "carrierwave" require "combustion" Bundler.require(:default) require "minitest/autorun" $logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) def mongoid? defined?(Mongoid) end require_relative "support/carrierwave" require_relative "support/shrine" if mongoid? require_relative "support/mongoid" else require_relative "support/combustion" require "carrierwave/orm/activerecord" require_relative "support/active_record" end Lockbox.master_key = SecureRandom.random_bytes(32) class Minitest::Test def with_master_key(key) previous_value = Lockbox.master_key begin Lockbox.master_key = key yield ensure Lockbox.master_key = previous_value end end def jruby? RUBY_ENGINE == "jruby" end def truffleruby? RUBY_ENGINE == "truffleruby" end end