diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 00000000..51ddd2a5 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,205 @@ +--- +kind: pipeline +type: docker +name: build + +trigger: + event: + - push + +platform: + os: linux + arch: amd64 + +anchors: + artifactory_credentials: &artifactory_credentials + ARTIFACTORY_USER: + from_secret: artifactory_user + ARTIFACTORY_PASSWORD: + from_secret: artifactory_password + vault_tag: &vault_tag + VAULT_VERSION: + from_secret: vault_version + commands: &commands + - apk add --no-cache curl + - curl -sSL -o /usr/local/bin/vault.zip https://releases.hashicorp.com/vault/$(echo $VAULT_VERSION)/vault_$(echo $VAULT_VERSION)_linux_amd64.zip + - unzip /usr/local/bin/vault.zip -d /usr/local/bin + - chmod +x /usr/local/bin/vault + - apk add --no-cache build-base sqlite-dev + - bundle install --jobs=4 --retry=3 + - bundle exec rake app:db:create + - bundle exec rake app:db:schema:load + - bundle exec rake app:db:test:prepare + - bundle exec rake + + +concurrency: + limit: 1 + +steps: + - name: build_rails4 + image: ruby:2.6-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_4.2.gemfile + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + + - name: build_rails5 + image: ruby:2.7-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_5.0.gemfile + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + depends_on: + - build_rails4 + + - name: build_rails51 + image: ruby:2.7-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_5.1.gemfile + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + depends_on: + - build_rails4 + - build_rails5 + + - name: build_rails52 + image: ruby:2.7-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_5.2.gemfile + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + depends_on: + - build_rails4 + - build_rails5 + - build_rails51 + + - name: build_rails6 + image: ruby:3.0-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_6.gemfile + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + depends_on: + - build_rails4 + - build_rails5 + - build_rails51 + - build_rails52 + + - name: build_rails7 + image: ruby:3.0-alpine + environment: + BUNDLE_GEMFILE: gemfiles/rails_7.gemfile + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + <<: [*vault_tag, *artifactory_credentials] + commands: *commands + depends_on: + - build_rails4 + - build_rails5 + - build_rails51 + - build_rails52 + - build_rails6 + + - name: publish_feature_branch_gem + image: quay.io/fundingcircle/alpine-ruby-builder:2.7 + environment: + <<: *artifactory_credentials + GEM_REPOSITORY: 'rubygems-pre-releases' + commands: + - gem install gem-versioner + - PRE_RELEASE=$(git rev-parse --short HEAD) gem build fc-vault-rails.gemspec + - | + mkdir -p ~/.gem + curl -u "$ARTIFACTORY_USER":"$ARTIFACTORY_PASSWORD" https://fundingcircle.jfrog.io/fundingcircle/api/gems/rubygems-pre-releases/api/v1/api_key.yaml > ~/.gem/credentials + chmod 600 ~/.gem/credentials + - | + package=$(ls -t1 fc-vault-rails*.gem | head -1) + gem push $package --host https://fundingcircle.jfrog.io/fundingcircle/api/gems/rubygems-pre-releases + depends_on: + - build_rails4 + - build_rails5 + - build_rails51 + - build_rails52 + - build_rails6 + - build_rails7 + when: + branch: + exclude: + - master + + +--- +kind: pipeline +type: docker +name: deploy + +trigger: + event: + - promote + when: + branch: + - master + +anchors: + artifactory_credentials: &artifactory_credentials + ARTIFACTORY_USER: + from_secret: artifactory_user + ARTIFACTORY_PASSWORD: + from_secret: artifactory_password + +# Platform for job, always Linux amd64 +platform: + os: linux + arch: amd64 + +steps: + - name: check_gem_version + image: quay.io/fundingcircle/alpine-ruby-builder:latest + environment: + <<: *artifactory_credentials + commands: + - bin/check_gem_version + depends_on: + - clone + + - name: publish_master_gem + image: quay.io/fundingcircle/alpine-ruby-builder:2.7 + environment: + <<: *artifactory_credentials + GEM_REPOSITORY: 'rubygems-local' + commands: + - gem build fc-vault-rails.gemspec + - | + mkdir -p ~/.gem + curl -u "$ARTIFACTORY_USER":"$ARTIFACTORY_PASSWORD" https://fundingcircle.jfrog.io/fundingcircle/api/gems/rubygems-local/api/v1/api_key.yaml > ~/.gem/credentials + chmod 600 ~/.gem/credentials + - | + package=$(ls -t1 fc-vault-rails*.gem | head -1) + gem push $package --host https://fundingcircle.jfrog.io/fundingcircle/api/gems/rubygems-local + depends_on: + - check_gem_version + + - name: tag_git_repo + image: quay.io/fundingcircle/alpine-ruby-builder:latest + environment: + GITHUB_TOKEN: + from_secret: github_token + commands: + - | + current_version="$(ruby -e 'require "./lib/vault/rails/version.rb";puts Vault::Rails::VERSION')"; + git tag v$current_version -m v$current_version; + git push --tags; + depends_on: + - publish_master_gem + + - name: status + image: quay.io/fundingcircle/drone-github-status:latest + settings: + env: production + locale: UK + depends_on: + - publish_master_gem diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..0718e925 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @FundingCircle/team-customer-sme diff --git a/.gitignore b/.gitignore index 3dd1753d..f21a75c9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +/.byebug_history ## Specific to RubyMotion: .dat* @@ -32,6 +33,7 @@ build/ Gemfile.lock .ruby-version .ruby-gemset +gemfiles/*.gemfile.lock # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index daba7fc9..00000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -language: ruby -cache: bundler -dist: trusty -sudo: false - -env: - - VAULT_VERSION=0.6.0 - - VAULT_VERSION=0.5.3 - - VAULT_VERSION=0.4.1 - - VAULT_VERSION=0.3.1 - -gemfile: - - Gemfile - - gemfiles/rails_4.1.gemfile - - gemfiles/rails_4.2.gemfile - -before_install: - - wget -O vault.zip -q https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip - - unzip vault.zip - - mkdir -p ~/bin - - mv vault ~/bin - - export PATH="~/bin:$PATH" - -branches: - only: - - master - -rvm: - - 2.2.8 - - 2.3.5 - - 2.4.2 - -matrix: - exclude: - # Rails 4.1 cannot build json gem dependency any longer with Ruby 2.4 - - rvm: 2.4.2 - gemfile: gemfiles/rails_4.1.gemfile - - rvm: 2.4.2 - gemfile: gemfiles/rails_4.2.gemfile - -before_script: - - bundle exec rake app:db:create - - bundle exec rake app:db:schema:load - - bundle exec rake app:db:test:prepare diff --git a/Appraisals b/Appraisals index 7a219b0f..965add0e 100644 --- a/Appraisals +++ b/Appraisals @@ -1,7 +1,40 @@ -appraise "rails-4.1" do - gem "rails", "~> 4.1.0" +appraise 'rails-4.2' do + gem 'rails', '~> 4.2.0' + gem 'sqlite3', '~> 1.3.13' + gem 'bundler', '~> 1.17.2' + gem 'tzinfo-data' end -appraise "rails-4.2" do - gem "rails", "~> 4.2.0" +appraise 'rails-5.0' do + gem 'rails', '~> 5.0.0' + gem 'sqlite3', '~> 1.3.13' + gem 'tzinfo-data' +end + +appraise 'rails-5.1' do + gem 'rails', '~> 5.1.0' + gem 'sqlite3', '~> 1.3.13' + gem 'tzinfo-data' +end + +appraise 'rails-5.2' do + gem 'rails', '~> 5.2.0' + gem 'sqlite3', '~> 1.3.13' + gem 'tzinfo-data' +end + +appraise 'rails-6' do + gem 'rails', '~> 6' + gem 'sqlite3', '~> 1.4' + gem 'tzinfo-data' +end + +appraise "rails-7" do + gem "rails", "~> 7" + gem 'sqlite3', '~> 1.4' +end + +appraise "rails-7" do + gem "rails", "~> 7" + gem 'sqlite3', '~> 1.4' end diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b57d6b..113cdef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,221 @@ # Vault Rails Changelog +## 2.2.0 (June 28, 2024) +* Adds `TransitJsonCodec.batch_*` functions which use the Vault batch API to process + an array of values with one API call. + * like `Vault::Rails.batch_decrypt` but with `transit` and FC's standard JSON pre-encoding of values. + +## 2.1.2 (November 17, 2023) + +IMPROVEMENTS +- Add Rails 7 Support +- Fix "DEPRECATION WARNING: connection_config is deprecated and will be removed from Rails 7.0" + +## 2.1.1 (January 31, 2022) + +NEW FEATURES +- Added `TransitJsonCodec` class which encrypt and decrypt JSON values + +## 2.1.0 (January 11, 2022) + +Prevent db queries on boot -> so that db:create / assets:precompile work + +## 2.0.5 (October 19, 2020) + +- Fix compatibility with `#with_lock` / `#lock!` - on initialization the `#changes` is no longer polluted. Fixed error: +``` +RuntimeError: Locking a record with unpersisted changes is not supported. Use `save` to persist the changes, or `reload` to discard them explicitly. +``` + +## 2.0.4 (December 2, 2019) + +IMPROVEMENTS +- Add Rails 6 Support +- Get rid of travis in the build pipeline + +## 2.0.3 (August 22, 2019) + +BUG FIXES +- Fix bug where JSONSerializer would raise an error when passed a string + +## 2.0.2 (May 16, 2019) + +IMPROVEMENTS +- Fixes issue when a blank string ciphertext is used by the `memory_decrypt` method. + +## 2.0.1 (May 2, 2019) + +NEW FEATURES +- Added `.unencrypted_attributes` which returns all attributes ignoring the `encrypted_column` + +IMPROVEMENTS +- Fixes issue with `.attributes` on rails >= 4.2 and < 5 now returning the `vault_attribute` correctly. + +## 2.0.0 (April 17, 2019) + +NEW FEATURES +- Added support for Rails 4.2.x + +IMPROVEMENTS +- No longer required to include the module `Vault::AttributeProxy` + +BREAKING CHANGES +- You can not pass an `ActiveRecord::Type` through the `type` option on `vault_attribute`, to do this just specify the type as a symbol. + +## 1.0.1 (March 14, 2019) + +NEW FEATURES +- Added `encrypted_where_not` finds encrypted records not matching the specified conditions + +## 1.0.0 (March 8, 2019) + +NEW FEATURES +- Added `encrypted_find_by` finds the first encrypted record matching the specified conditions +- Added `encrypted_find_by!` like `encrypted_find_by`, except that if no record is found, raises an `ActiveRecord::RecordNotFound` error. + +IMPROVEMENTS +- `find_by_vault_attributes` renamed to `encrypted_where` as it returns a relation rather than a single record + +BREAKING CHANGES +- `find_by_vault_attributes` renamed to `encrypted_where` + +## 0.7.7 (March 6, 2019) + +IMPROVEMENTS +- Updates error message when `vault_uniqueness` is used, so now the `vault_attribute`'s name is used rather than the encrypted column name + +## 0.7.6 (February 27, 2019) + +IMPROVEMENTS +- Add option to `PerformInBatches#encrypt` and `EncryptedModel.vault_persist_all` to skip `ActiveRecord` validations +- Drop support of Ruby 2.2 + +## 0.7.5 (December 17, 2018) + +IMPROVEMENTS +- Add method for database searching by convergently encrypted attributes +- Add uniqueness validator for convergently encrypted attributes + +## 0.7.4 (December 12, 2018) + +IMPROVEMENTS +- Add `EncryptedModel.vault_persist_all` for encrypting and saving one attribute of multiple records with just one call to Vault (forward ported from 0.6.5) +- Add `EncryptedModel.vault_load_all` for decrypting and loading one attribute of multiple records with just one call to Vault (forward ported from 0.6.5) + +## 0.7.3 (December 10, 2018) + +BUG FIXES +- Allow blank values like `nil` and empty string as input to batch encryption and decryption (forward ported from 0.6.5) +- Handle the case when plaintexts/ciphertexts parameter of #vault_batch_encrypt/#vault_batch_decrypt is an array with only blank values (forward ported from 0.6.7) + +## 0.7.2 (December 3, 2018) + +NEW FEATURES +- New serializers for `time` and `datetime` +- Allow symbol values for `type` to find any type class registered with + `ActiveRecord::Type`, not just the constants defined under it +- If `type` is specified but serialization options aren't then attempt to + detect a default serializer based on the type. +- New serializer for `ipaddr`, which acts as a default for `inet` and + `cidr` too. + +BREAKING CHANGES +- Actually drop support for rails 4.x, we should have done this in 0.7.0 + +## v0.7.1 (November 21, 2018) + +NEW FEATURES +- Support for batch encryption/decryption via `Vault::Rails.batch_encrypt` + and `Vault::Rails.batch_decrypt` methods. +- Introduce deprecation warnings for the breaking changes between 0.6 and + 0.7. This includes adding back `Vault::AttributeProxy` as an empty + module that generates a deprecation warning. + +BUG FIXES +- Actually persist encrypted attributes when using + `vault_persist_before_save!` in rails 5.2 +- Support lazy loading of `nil` values. + +## v0.7.0 (October 24, 2018) + +NOTABLE CHANGES + - Use ActiveRecord Attribute API to implement encrypted attributes + - Add support for ActiveRecord >= 5.2 and ActiveRecord < 6.0 + +BREAKING CHANGES + - `vault_attribute_proxy` is included with `Vault::EncryptedModel` by + default, there is no `Valut::AttributeProxy` module any more. + - type information is now specified on `vault_attribute` definitions + instead of the `vault_attribute_proxy` definitions. + +## v0.6.7 (December 7, 2018) + +BUG FIXES +- Handle the case when plaintexts/ciphertexts parameter of #vault_batch_encrypt/#vault_batch_decrypt is an array with only blank values + +## v0.6.6 (December 3, 2018) + +NEW FEATURES +- New serializers for `time` and `datetime` +- New serializer for `ipaddr`. + +## v0.6.5 (November 28, 2018) + +IMPROVEMENTS +- Add `EncryptedModel.vault_persist_all` for encrypting and saving one attribute of multiple records with just one call to Vault +- Add `EncryptedModel.vault_load_all` for decrypting and loading one attribute of multiple records with just one call to Vault +- Allow blank values like `nil` and empty string as input to batch encryption and decryption + +## v0.6.4 (November 13, 2018) + +NEW FEATURES +- Allow batch encryption and decryption. + Now there is an option to encrypt or decrypt multiple strings at once. + All items to be encrypted/decrypted should use the same path, key and client. + +## v0.6.3 (October 31, 2018) + +NEW FEATURES +- Allow specifying type information on `vault_attribute_proxy` definitions. + This allows the proxied attribute to convert between strings (what all + values ultimately are when send to vault for encryption) and the typed + representation that we'd otherwise get from a traditional activerecord + database-backed attribute. + +## v0.6.2 (October 30, 2018) + +NEW FEATURES +- Introduce `vault_attribute_proxy` via including Vault::AttributeProxy. + This acts to unify an existing plaintext column with a new encryped + column defined as a `vault_attribute`. Allowing a staged transition to + a fully encrypted attribute at a later date. + +## v0.6.1 (October 16, 2018) + +NEW FEATURES +- Allow specifying encoding for decrypted values via `Vault::Rails.encoding` + +BUG FIXES +- Stop relying on Rails for default encoding of decrypted values +- Use `ActiveRecord::Base.logger` instead of `Rails.logger` +- When serialising JSON values pass through nil values as nil, not `{}` + +## v0.6.0 (October 15, 2018) + +NOTABLE CHANGES + +- Removed 4.1 dependency +- Change dependency from Rails to ActiveRecord + +## v0.5.0 (October 9, 2018) +NEW FEATURES +- Convergent Encryption +- New serializers +- Encrypting attributes on before_save + +IMPROVEMENTS +- Improved lazy decryption + ## v0.4.0 (November 9, 2017) - Update supported Ruby and Rails versions [GH-50] - Ruby diff --git a/README.md b/README.md index 9b93c81b..9fc93957 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -Vault Rails [![Build Status](https://secure.travis-ci.org/hashicorp/vault-rails.svg?branch=master)](http://travis-ci.org/hashicorp/vault-rails) +Vault Rails [![CircleCI](https://circleci.com/gh/FundingCircle/vault-rails/tree/master.svg?style=svg)](https://circleci.com/gh/FundingCircle/vault-rails/tree/master) =========== +> [!IMPORTANT] +> Library has been recently updated to provide support to Rails 7, it was technically tested but if you find error integrating it, drop a message in the +> [\#customerdata team channel](https://fundingcircle.slack.com/archives/C03PNLY44M6) + Vault is the official Rails plugin for interacting with [Vault](https://vaultproject.io) by HashiCorp. **The documentation in this README corresponds to the master branch of the Vault Rails plugin. It may contain unreleased features or different APIs than the most recently released version. Please see the Git tag that corresponds to your version of the Vault Rails plugin for the proper documentation.** @@ -109,6 +113,31 @@ vault_attribute :credit_card, - **Note** Changing this value for an existing application will make existing values no longer decryptable! +#### Attribute types +The latest version of VaultRails uses ActiveRecord's Attribute API to implement encrypted attributes. This allows us to use its's internal type casting mechanism, and makes encrypted attributes behave like normal ActiveRecord ones. If you don't specify a type, the default is ActiveRecord::Type::Value, which can hold any value. + +Since Vault ciphertexts are always Base64 encoded strings, we still need to tell ActiveRecord how to handle this. ActiveRecord knows how to convert between ruby objects and datatypes that the database understands, but this is not useful to us in this case. There are a number of ways to deal with this: + * use serializers + * use `encode`/`decode` procs + * Define your own types (inherit from `ActiveRecord::Type::Value`) and override the `type_cast_from_database`/`type_cast_for_database` (AR 4.2) or `serialize`/`deserialize` (AR 5+) + +```ruby +class User < ActiveRecord::Base + include Vault::EncryptedModel + vault_attribute :date_of_birth, + type: :date, + encode: -> (raw) { raw.to_s if raw }, + decode: -> (raw) { raw.to_date if raw } +end + +>> user = User.new +=> # +>> user.date_of_birth = '1988-10-15' +=> "1988-10-15" +>> user.date_of_birth.class +=> Date +``` + #### Automatic serializing By default, all values are assumed to be "text" fields in the database. Sometimes it is beneficial for your application to work with a more flexible data structure (such as a Hash or Array). Vault-rails can automatically serialize and deserialize these structures for you: @@ -117,6 +146,12 @@ vault_attribute :details serialize: :json ``` +This is the list of included serializers: + * `:json` + * `:date` + * `:integer` + * `:float` + - **Note** You can view the source for the exact serialization and deserialization options, but they are intentionally not customizable and cannot be used for a full object marshal/unmarshal. For customized solutions, you can also pass a module to the `:serializer` key. This module must have the following API: @@ -153,9 +188,18 @@ vault_attribute :address, - **Note** Changing the algorithm for encoding/decoding for an existing application will probably make the application crash when attempting to retrive existing values! +### Lazy Decryption +VaultRails decrypts all the encrypted attributes in an `after_initialize` callback. Although this is useful sometimes, other times it may be unnecessary. For example you may not need all or any of the encrypted attributes. +In such cases, you can use `vault_lazy_decrypt!` in your model, and VaultRails will decrypt the attributes, one by one, only when they are needed. + Caveats ------- +### Saving encrypted attributes +By default, VaultRails will encrypt and then save the encrypted attributes in an `after_save` callback. This results in a second query to the database. If you'd like to avoid this and encrypt the attributes before the model is saved, you can use `vault_persist_before_save!` in your model, and it will encrypt the attribues in a `before_save` callback. + +-- **Note** You'll need to make sure that no other callbacks interfere with these callbacks e.g. (modify the ciphertext). + ### Mounting/Creating Keys in Vault The Vault Rails plugin does not automatically mount a backend. It is assumed the proper backend is mounted and accessible by the given token. You can mount a transit backend like this: @@ -202,18 +246,110 @@ So for the example above, the key would be: my_app_people_ssn +### Convergent Encryption +Convergent encryption is a mode where the same set of plaintext and context always result in the same ciphertext. It does this by deriving a key using a key derivation function but also by deterministically deriving a nonce. You can use this if you need to check for uniqueness, or if you need the ability to search (exact-match). + +Vault supports convergent encryption since v0.6.1. We take advantage of this functionality. + +You'll need to provide an encryption context for the key derivation function in order to use convergent encryption. +```ruby +Vault::Rails.configure do |vault| + vault.convergent_encryption_context = ENV['CONVERGENT_ENCRYPTION_CONTEXT'] +end +``` + +Then, you can tell Vault to use convergent encryption like so: +```ruby +vault_attribute :ssn, + convergent: true +``` + +- **Note** Convergent encryption significantly weakens the security that encryption provides. Use this with caution! + +### Batch encryption and decryption +There is an option to encrypt or decrypt multiple strings at once. +All items to be encrypted/decrypted should use the same path, key and client. + +``` ruby +Vault::Rails.batch_decrypt(path, key, , client) +Vault::Rails.batch_encrypt(path, key, , client) +``` + +Even easier, you could use: + +* ```EncryptedModel.vault_persist_all(attribute, records, plaintexts, validate: true)``` + + Encrypt all plaintext values and save them as the given attribute for the corresponding record + If you pass `validate: false` to `vault_persist_all` objects will be saved without validations. By default, validations are turned on. + +* ```EncryptedModel.vault_load_all(attribute, records)``` + + Decrypt and load the given attribute for each of the records + + + ### Searching Encrypted Attributes Because each column is uniquely encrypted, it is not possible to search for a -particular plain-text value. For example, if the `ssn` attribute is encrypted, +particular plain-text value with a plain `ActiveRecord` query. For example, if the `ssn` attribute is encrypted, the following will **NOT** work: ```ruby Person.where(ssn: "123-45-6789") ``` -This is because the database is unaware of the plain-text data (which is part of -the security model). +That's why we have added a method that provides an easy to use search interface. Instead of using `.where` you can use +`.encrypted_where`. Example: + +```ruby +Person.encrypted_where(driving_licence_number: '12345678') +``` + +This method will look up seamlessly in the relevant column with encrypted data. +It is important to note that you can search only for attributes with **convergent** encryption. +Similar to `.where` the method `.encrypted_where` also returns an `ActiveRecord::Relation` + +Along with `.encrypted_where` we also have `.encrypted_where_not` which finds encrypted records not matching the specified conditions acts like `.where.not` + +There is also `.encrypted_find_by` which works like `.find_by` finds the first encrypted record matching the specified conditions. + +```ruby +Personal.encrypted_find_by(driving_licence_number: '12345678') +``` + +and `.encrypted_find_by!` like `encrypted_find_by`, except that if no record is found, raises an `ActiveRecord::RecordNotFound` error. + +```ruby +Personal.encrypted_find_by!(driving_licence_number: '12345678') +``` + +### Uniqueness Validation +If a column is **convergently** encrypted, it is possible to add a validation of uniqueness to it. +Example: +```ruby +validates :driving_licence_number, vault_uniqueness: true +``` + +It is highly advisable that you also add a uniqueness constraint at database level. + +### Vault Attribute Proxy +This method is useful if you have a plaintext attribute that you want to replace with a vault attribute. +During a transition period both attributes can be seamlessly read/changed at the same time. +Then by using the boolean option `encrypted_attribute_only`, you will be able to test if the ciphertext field works as expected before getting rid of the plaintext attribute. +In order to use this method for an attribute you need to add the following row in your model for given plaintext and ciphertext attributes: +```ruby +vault_attribute_proxy :attribute, :attribute_ciphertext, encrypted_attribute_only: true +``` + +Upgrading to the latest version from 0.6.x +------------------------- +Master now targets both rails 4.2.x and rails 5.x. There are breaking changes between the two versions too, so upgrading isn't as smooth as it could be. + +1. You no longer need to `include Vault::AttributeProxy` to get `vault_attribute_proxy` as it is part of `Vault::EncryptedModel` now in both versions. + + **If you do nothing** your app will still work properly in master, but you'll get annoying messages. + +2. Passing a type as an object is no longer supported `type: ActiveRecord::Type::Time.new` if you need to use a `ActiveRecord::Type` pass it as a symbol e.g. `type: :time`. Development ----------- @@ -226,3 +362,11 @@ Important Notes: - **All new features must include test coverage.** At a bare minimum, Unit tests are required. It is preferred if you include acceptance tests as well. - **The tests must be be idempotent.** The HTTP calls made during a test should be able to be run over and over. - **Tests are order independent.** The default RSpec configuration randomizes the test order, so this should not be a problem. + +We now have two versions of `Vault::EncryptedModel` a `Latest` version which targets rails 5.x and up and a `Legacy` version which targets 4.2.x. It made sense to keep these two version seperate from one another because of the amount of differences between them. So if changes need to be applied to support both versions both files must be changed. + +Getting tests to run +-------------------- +``` +$ bundle exec rake db:schema:load +``` diff --git a/bin/check_gem_version b/bin/check_gem_version new file mode 100755 index 00000000..2ba2ec81 --- /dev/null +++ b/bin/check_gem_version @@ -0,0 +1,14 @@ +echo -n "Checking existing available versions of gem... "; +current_version="$(ruby -e 'require "./lib/vault/rails/version.rb";puts Vault::Rails::VERSION')"; +version_info_json=$(curl -s -u "$ARTIFACTORY_USER":"$ARTIFACTORY_PASSWORD" https://fundingcircle.jfrog.io/fundingcircle/api/gems/rubygems-local/api/v1/versions/fc-vault-rails.json); + +if echo "$version_info_json" | jq -e '[.[] | select(.number|test("'$current_version'"))]|length==0' > /dev/null; then + echo -e '\e[32mOK\e[0m'; +else + echo -e '\e[31mFAIL\e[0m'; + echo 'Existing published versions:'; + echo "$version_info_json" | jq 'map(.number)'; + echo -e 'Your version: \e[31m'$current_version'\e[0m.'; + echo 'Please bump the version in `lib/vault/rails/version.rb`.' + exit 1; +fi diff --git a/vault.gemspec b/fc-vault-rails.gemspec similarity index 52% rename from vault.gemspec rename to fc-vault-rails.gemspec index 768c0b00..dfa6dd9d 100644 --- a/vault.gemspec +++ b/fc-vault-rails.gemspec @@ -5,11 +5,11 @@ require "vault/rails/version" # Describe your gem and declare its dependencies: Gem::Specification.new do |s| - s.name = "vault-rails" + s.name = "fc-vault-rails" s.version = Vault::Rails::VERSION - s.authors = ["Seth Vargo"] - s.email = ["sethvargo@gmail.com"] - s.homepage = "https://github.com/hashicorp/vault-rails" + s.authors = ["Funding Circle Engineering", "Seth Vargo"] + s.email = ["engineering+fc-vault-rails@fundingcircle.com", "sethvargo@gmail.com"] + s.homepage = "https://github.com/fundingcircle/fc-vault-rails" s.summary = "Official Vault plugin for Rails" s.description = s.summary s.license = "MPL-2.0" @@ -17,13 +17,17 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,db,lib}/**/*", "LICENSE", "Rakefile", "README.md"] s.test_files = Dir["spec/**/*"] - s.add_dependency "rails", [">= 4.1", "< 5.1"] - s.add_dependency "vault", "~> 0.5" + s.add_dependency "activerecord" + s.add_dependency "vault", "~> 0.7" + s.add_dependency "tzinfo-data" s.add_development_dependency "appraisal", "~> 2.1" s.add_development_dependency "bundler" + s.add_development_dependency "rails", "~> 6" + s.add_development_dependency "byebug" s.add_development_dependency "pry" - s.add_development_dependency "rake", "~> 10.0" + s.add_development_dependency "rake" s.add_development_dependency "rspec", "~> 3.2" - s.add_development_dependency "sqlite3" + s.add_development_dependency "sqlite3", '~> 1.4' + s.add_development_dependency "oj" end diff --git a/gemfiles/.bundle/config b/gemfiles/.bundle/config new file mode 100644 index 00000000..1d367dbc --- /dev/null +++ b/gemfiles/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_RETRY: "1" +BUNDLE_WITH: "development" diff --git a/gemfiles/rails_4.1.gemfile.lock b/gemfiles/rails_4.1.gemfile.lock deleted file mode 100644 index 4e6eeb78..00000000 --- a/gemfiles/rails_4.1.gemfile.lock +++ /dev/null @@ -1,124 +0,0 @@ -PATH - remote: .. - specs: - vault-rails (0.4.0) - rails (>= 4.1, < 5.1) - vault (~> 0.5) - -GEM - remote: https://rubygems.org/ - specs: - actionmailer (4.1.0) - actionpack (= 4.1.0) - actionview (= 4.1.0) - mail (~> 2.5.4) - actionpack (4.1.0) - actionview (= 4.1.0) - activesupport (= 4.1.0) - rack (~> 1.5.2) - rack-test (~> 0.6.2) - actionview (4.1.0) - activesupport (= 4.1.0) - builder (~> 3.1) - erubis (~> 2.7.0) - activemodel (4.1.0) - activesupport (= 4.1.0) - builder (~> 3.1) - activerecord (4.1.0) - activemodel (= 4.1.0) - activesupport (= 4.1.0) - arel (~> 5.0.0) - activesupport (4.1.0) - i18n (~> 0.6, >= 0.6.9) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.1) - tzinfo (~> 1.1) - appraisal (2.2.0) - bundler - rake - thor (>= 0.14.0) - arel (5.0.1.20140414130214) - builder (3.2.3) - coderay (1.1.1) - concurrent-ruby (1.0.5) - diff-lcs (1.3) - erubis (2.7.0) - i18n (0.8.6) - json (1.8.3) - mail (2.5.5) - mime-types (~> 1.16) - treetop (~> 1.4.8) - method_source (0.8.2) - mime-types (1.25.1) - minitest (5.10.3) - polyglot (0.3.5) - pry (0.10.4) - coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - rack (1.5.5) - rack-test (0.6.3) - rack (>= 1.0) - rails (4.1.0) - actionmailer (= 4.1.0) - actionpack (= 4.1.0) - actionview (= 4.1.0) - activemodel (= 4.1.0) - activerecord (= 4.1.0) - activesupport (= 4.1.0) - bundler (>= 1.3.0, < 2.0) - railties (= 4.1.0) - sprockets-rails (~> 2.0) - railties (4.1.0) - actionpack (= 4.1.0) - activesupport (= 4.1.0) - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) - rake (10.5.0) - rspec (3.5.0) - rspec-core (~> 3.5.0) - rspec-expectations (~> 3.5.0) - rspec-mocks (~> 3.5.0) - rspec-core (3.5.2) - rspec-support (~> 3.5.0) - rspec-expectations (3.5.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-mocks (3.5.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-support (3.5.0) - slop (3.6.0) - sprockets (3.7.1) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (2.3.3) - actionpack (>= 3.0) - activesupport (>= 3.0) - sprockets (>= 2.8, < 4.0) - sqlite3 (1.3.13) - thor (0.19.4) - thread_safe (0.3.6) - treetop (1.4.15) - polyglot - polyglot (>= 0.3.1) - tzinfo (1.2.3) - thread_safe (~> 0.1) - vault (0.10.1) - -PLATFORMS - ruby - -DEPENDENCIES - appraisal (~> 2.1) - bundler - pry - rails (~> 4.1.0) - rake (~> 10.0) - rspec (~> 3.2) - sqlite3 - vault-rails! - -BUNDLED WITH - 1.15.3 diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile index cd8b45b1..b70b0322 100644 --- a/gemfiles/rails_4.2.gemfile +++ b/gemfiles/rails_4.2.gemfile @@ -3,5 +3,8 @@ source "https://rubygems.org" gem "rails", "~> 4.2.0" +gem "sqlite3", "~> 1.3.13" +gem "bundler", "~> 1.17.2" +gem "tzinfo-data" -gemspec :path => "../" +gemspec path: "../" diff --git a/gemfiles/rails_4.2.gemfile.lock b/gemfiles/rails_4.2.gemfile.lock deleted file mode 100644 index 762ac15d..00000000 --- a/gemfiles/rails_4.2.gemfile.lock +++ /dev/null @@ -1,148 +0,0 @@ -PATH - remote: .. - specs: - vault-rails (0.4.0) - rails (>= 4.1, < 5.1) - vault (~> 0.5) - -GEM - remote: https://rubygems.org/ - specs: - actionmailer (4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.7.1) - actionview (= 4.2.7.1) - activesupport (= 4.2.7.1) - rack (~> 1.6) - rack-test (~> 0.6.2) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.7.1) - activesupport (= 4.2.7.1) - builder (~> 3.1) - erubis (~> 2.7.0) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - activejob (4.2.7.1) - activesupport (= 4.2.7.1) - globalid (>= 0.3.0) - activemodel (4.2.7.1) - activesupport (= 4.2.7.1) - builder (~> 3.1) - activerecord (4.2.7.1) - activemodel (= 4.2.7.1) - activesupport (= 4.2.7.1) - arel (~> 6.0) - activesupport (4.2.7.1) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - appraisal (2.2.0) - bundler - rake - thor (>= 0.14.0) - arel (6.0.3) - builder (3.2.3) - coderay (1.1.1) - concurrent-ruby (1.0.5) - diff-lcs (1.3) - erubis (2.7.0) - globalid (0.4.0) - activesupport (>= 4.2.0) - i18n (0.8.6) - json (1.8.3) - loofah (2.0.3) - nokogiri (>= 1.5.9) - mail (2.6.6) - mime-types (>= 1.16, < 4) - method_source (0.8.2) - mime-types (3.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) - minitest (5.10.3) - nokogiri (1.6.8) - mini_portile2 (~> 2.1.0) - pkg-config (~> 1.1.7) - pkg-config (1.1.7) - pry (0.10.4) - coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - rack (1.6.4) - rack-test (0.6.3) - rack (>= 1.0) - rails (4.2.7.1) - actionmailer (= 4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) - activemodel (= 4.2.7.1) - activerecord (= 4.2.7.1) - activesupport (= 4.2.7.1) - bundler (>= 1.3.0, < 2.0) - railties (= 4.2.7.1) - sprockets-rails - rails-deprecated_sanitizer (1.0.3) - activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.7) - activesupport (>= 4.2.0.beta, < 5.0) - nokogiri (~> 1.6.0) - rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) - railties (4.2.7.1) - actionpack (= 4.2.7.1) - activesupport (= 4.2.7.1) - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) - rake (10.5.0) - rspec (3.5.0) - rspec-core (~> 3.5.0) - rspec-expectations (~> 3.5.0) - rspec-mocks (~> 3.5.0) - rspec-core (3.5.2) - rspec-support (~> 3.5.0) - rspec-expectations (3.5.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-mocks (3.5.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-support (3.5.0) - slop (3.6.0) - sprockets (3.7.1) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.0) - actionpack (>= 4.0) - activesupport (>= 4.0) - sprockets (>= 3.0.0) - sqlite3 (1.3.13) - thor (0.19.4) - thread_safe (0.3.6) - tzinfo (1.2.3) - thread_safe (~> 0.1) - vault (0.10.1) - -PLATFORMS - ruby - -DEPENDENCIES - appraisal (~> 2.1) - bundler - pry - rails (~> 4.2.0) - rake (~> 10.0) - rspec (~> 3.2) - sqlite3 - vault-rails! - -BUNDLED WITH - 1.15.3 diff --git a/gemfiles/rails_5.0.gemfile b/gemfiles/rails_5.0.gemfile new file mode 100644 index 00000000..997d7223 --- /dev/null +++ b/gemfiles/rails_5.0.gemfile @@ -0,0 +1,9 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 5.0.0" +gem "sqlite3", "~> 1.3.13" +gem "tzinfo-data" + +gemspec path: "../" diff --git a/gemfiles/rails_5.1.gemfile b/gemfiles/rails_5.1.gemfile new file mode 100644 index 00000000..8fe3549b --- /dev/null +++ b/gemfiles/rails_5.1.gemfile @@ -0,0 +1,9 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 5.1.0" +gem "sqlite3", "~> 1.3.13" +gem "tzinfo-data" + +gemspec path: "../" diff --git a/gemfiles/rails_5.2.gemfile b/gemfiles/rails_5.2.gemfile new file mode 100644 index 00000000..0c9f9b03 --- /dev/null +++ b/gemfiles/rails_5.2.gemfile @@ -0,0 +1,9 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 5.2.0" +gem "sqlite3", "~> 1.3.13" +gem "tzinfo-data" + +gemspec path: "../" diff --git a/gemfiles/rails_6.gemfile b/gemfiles/rails_6.gemfile new file mode 100644 index 00000000..1ca774ca --- /dev/null +++ b/gemfiles/rails_6.gemfile @@ -0,0 +1,9 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 6" +gem "sqlite3", "~> 1.4" +gem "tzinfo-data" + +gemspec path: "../" diff --git a/gemfiles/rails_4.1.gemfile b/gemfiles/rails_7.gemfile similarity index 52% rename from gemfiles/rails_4.1.gemfile rename to gemfiles/rails_7.gemfile index f95005cd..1caf2486 100644 --- a/gemfiles/rails_4.1.gemfile +++ b/gemfiles/rails_7.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" -gem "rails", "~> 4.1.0" +gem "rails", "~> 7" +gem "sqlite3", "~> 1.4" -gemspec :path => "../" +gemspec path: "../" diff --git a/lib/vault/attribute_proxy.rb b/lib/vault/attribute_proxy.rb new file mode 100644 index 00000000..db5e3594 --- /dev/null +++ b/lib/vault/attribute_proxy.rb @@ -0,0 +1,12 @@ +require "active_support/concern" +require "active_support/deprecation" + +module Vault + module AttributeProxy + extend ActiveSupport::Concern + + included do + ActiveSupport::Deprecation.warn('Vault::AttributeProxy is no longer required, `vault_attribute_proxy` comes via `Vault::EncryptedModel` so you can remove `include Vault::AttributeProxy` from your model.') + end + end +end diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index 64441ed1..1038f5ee 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -1,289 +1,11 @@ -require "active_support/concern" +require "active_record" +require_relative "latest/encrypted_model" +require_relative "legacy/encrypted_model" module Vault - module EncryptedModel - extend ActiveSupport::Concern - - module ClassMethods - # Creates an attribute that is read and written using Vault. - # - # @example - # - # class Person < ActiveRecord::Base - # include Vault::EncryptedModel - # vault_attribute :ssn - # end - # - # person = Person.new - # person.ssn = "123-45-6789" - # person.save - # person.encrypted_ssn #=> "vault:v0:6hdPkhvyL6..." - # - # @param [Symbol] column - # the column that is encrypted - # @param [Hash] options - # - # @option options [Symbol] :encrypted_column - # the name of the encrypted column (default: +#{column}_encrypted+) - # @option options [String] :path - # the path to the transit backend (default: +transit+) - # @option options [String] :key - # the name of the encryption key (default: +#{app}_#{table}_#{column}+) - # @option options [Symbol, Class] :serializer - # the name of the serializer to use (or a class) - # @option options [Proc] :encode - # a proc to encode the value with - # @option options [Proc] :decode - # a proc to decode the value with - def vault_attribute(attribute, options = {}) - encrypted_column = options[:encrypted_column] || "#{attribute}_encrypted" - path = options[:path] || "transit" - key = options[:key] || "#{Vault::Rails.application}_#{table_name}_#{attribute}" - - # Sanity check options! - _vault_validate_options!(options) - - # Get the serializer if one was given. - serializer = options[:serialize] - - # Unless a class or module was given, construct our serializer. (Slass - # is a subset of Module). - if serializer && !serializer.is_a?(Module) - serializer = Vault::Rails.serializer_for(serializer) - end - - # See if custom encoding or decoding options were given. - if options[:encode] && options[:decode] - serializer = Class.new - serializer.define_singleton_method(:encode, &options[:encode]) - serializer.define_singleton_method(:decode, &options[:decode]) - end - - # Getter - define_method("#{attribute}") do - self.__vault_load_attributes! unless @__vault_loaded - instance_variable_get("@#{attribute}") - end - - # Setter - define_method("#{attribute}=") do |value| - self.__vault_load_attributes! unless @__vault_loaded - - # We always set it as changed without comparing with the current value - # because we allow our held values to be mutated, so we need to assume - # that if you call attr=, you want it send back regardless. - - attribute_will_change!("#{attribute}") - instance_variable_set("@#{attribute}", value) - - # Return the value to be consistent with other AR methods. - value - end - - # Checker - define_method("#{attribute}?") do - self.__vault_load_attributes! unless @__vault_loaded - instance_variable_get("@#{attribute}").present? - end - - # Dirty method - define_method("#{attribute}_change") do - changes["#{attribute}"] - end - - # Dirty method - define_method("#{attribute}_changed?") do - changed.include?("#{attribute}") - end - - # Dirty method - define_method("#{attribute}_was") do - if changes["#{attribute}"] - changes["#{attribute}"][0] - else - public_send("#{attribute}") - end - end - - # Make a note of this attribute so we can use it in the future (maybe). - __vault_attributes[attribute.to_sym] = { - key: key, - path: path, - serializer: serializer, - encrypted_column: encrypted_column, - } - - self - end - - # The list of Vault attributes. - # - # @return [Hash] - def __vault_attributes - @vault_attributes ||= {} - end - - # Validate that Vault options are all a-okay! This method will raise - # exceptions if something does not make sense. - def _vault_validate_options!(options) - if options[:serializer] - if options[:encode] || options[:decode] - raise Vault::Rails::ValidationFailedError, "Cannot use a " \ - "custom encoder/decoder if a `:serializer' is specified!" - end - end - - if options[:encode] && !options[:decode] - raise Vault::Rails::ValidationFailedError, "Cannot specify " \ - "`:encode' without specifying `:decode' as well!" - end - - if options[:decode] && !options[:encode] - raise Vault::Rails::ValidationFailedError, "Cannot specify " \ - "`:decode' without specifying `:encode' as well!" - end - end - - def vault_lazy_decrypt - @vault_lazy_decrypt ||= false - end - - def vault_lazy_decrypt! - @vault_lazy_decrypt = true - end - end - - included do - # After a resource has been initialized, immediately communicate with - # Vault and decrypt any attributes unless vault_lazy_decrypt is set. - after_initialize :__vault_initialize_attributes! - - # After we save the record, persist all the values to Vault and reload - # them attributes from Vault to ensure we have the proper attributes set. - # The reason we use `after_save` here is because a `before_save` could - # run too early in the callback process. If a user is changing Vault - # attributes in a callback, it is possible that our callback will run - # before theirs, resulting in attributes that are not persisted. - after_save :__vault_persist_attributes! - - # Decrypt all the attributes from Vault. - # @return [true] - def __vault_initialize_attributes! - if self.class.vault_lazy_decrypt - @__vault_loaded = false - return - end - - __vault_load_attributes! - end - - def __vault_load_attributes! - self.class.__vault_attributes.each do |attribute, options| - self.__vault_load_attribute!(attribute, options) - end - - @__vault_loaded = true - - return true - end - - # Decrypt and load a single attribute from Vault. - def __vault_load_attribute!(attribute, options) - key = options[:key] - path = options[:path] - serializer = options[:serializer] - column = options[:encrypted_column] - - # Load the ciphertext - ciphertext = read_attribute(column) - - # If the user provided a value for the attribute, do not try to load - # it from Vault - if instance_variable_get("@#{attribute}") - return - end - - # Load the plaintext value - plaintext = Vault::Rails.decrypt(path, key, ciphertext) - - # Deserialize the plaintext value, if a serializer exists - if serializer - plaintext = serializer.decode(plaintext) - end - - # Write the virtual attribute with the plaintext value - instance_variable_set("@#{attribute}", plaintext) - end - - # Encrypt all the attributes using Vault and set the encrypted values back - # on this model. - # @return [true] - def __vault_persist_attributes! - changes = {} - - self.class.__vault_attributes.each do |attribute, options| - if c = self.__vault_persist_attribute!(attribute, options) - changes.merge!(c) - end - end - - # If there are any changes to the model, update them all at once, - # skipping any callbacks and validation. This is okay, because we are - # already in a transaction due to the callback. - if !changes.empty? - self.update_columns(changes) - end - - return true - end - - # Encrypt a single attribute using Vault and persist back onto the - # encrypted attribute value. - def __vault_persist_attribute!(attribute, options) - key = options[:key] - path = options[:path] - serializer = options[:serializer] - column = options[:encrypted_column] - - # Only persist changed attributes to minimize requests - this helps - # minimize the number of requests to Vault. - if !changed.include?("#{attribute}") - return - end - - # Get the current value of the plaintext attribute - plaintext = instance_variable_get("@#{attribute}") - - # Apply the serialize to the plaintext value, if one exists - if serializer - plaintext = serializer.encode(plaintext) - end - - # Generate the ciphertext and store it back as an attribute - ciphertext = Vault::Rails.encrypt(path, key, plaintext) - - # Write the attribute back, so that we don't have to reload the record - # to get the ciphertext - write_attribute(column, ciphertext) - - # Return the updated column so we can save - { column => ciphertext } - end - - # Override the reload method to reload the Vault attributes. This will - # ensure that we always have the most recent data from Vault when we - # reload a record from the database. - def reload(*) - super.tap do - # Unset all the instance variables to force the new data to be pulled - # from Vault - self.class.__vault_attributes.each do |attribute, _| - self.instance_variable_set("@#{attribute}", nil) - end - - self.__vault_initialize_attributes! - end - end - end + EncryptedModel = if Vault::Rails.latest? + Latest::EncryptedModel + else + Legacy::EncryptedModel end end diff --git a/lib/vault/latest/encrypted_model.rb b/lib/vault/latest/encrypted_model.rb new file mode 100644 index 00000000..14f229cd --- /dev/null +++ b/lib/vault/latest/encrypted_model.rb @@ -0,0 +1,423 @@ +require "active_support/concern" +require "active_record" +require "active_record/type" + +module Vault + module Latest + module EncryptedModel + extend ActiveSupport::Concern + + module ClassMethods + # Creates an attribute that is read and written using Vault. + # + # @example + # + # class Person < ActiveRecord::Base + # include Vault::EncryptedModel + # vault_attribute :ssn + # end + # + # person = Person.new + # person.ssn = "123-45-6789" + # person.save + # person.encrypted_ssn #=> "vault:v0:6hdPkhvyL6..." + # + # @param [Symbol] column + # the column that is encrypted + # @param [Hash] options + # + # @option options [Symbol] :encrypted_column + # the name of the encrypted column (default: +#{column}_encrypted+) + # @option options [Bool] :convergent + # use convergent encryption (default: +false+) + # @option options [String] :path + # the path to the transit backend (default: +transit+) + # @option options [String] :key + # the name of the encryption key (default: +#{app}_#{table}_#{column}+) + # @option options [Symbol, Class] :serializer + # the name of the serializer to use (or a class) + # @option options [Proc] :encode + # a proc to encode the value with + # @option options [Proc] :decode + # a proc to decode the value with + def vault_attribute(attribute_name, options = {}) + encrypted_column = options[:encrypted_column] || "#{attribute_name}_encrypted" + path = options[:path] || "transit" + key = options[:key] || "#{Vault::Rails.application}_#{table_name}_#{attribute_name}" + convergent = options.fetch(:convergent, false) + + # Sanity check options! + _vault_validate_options!(options) + + attribute_type = _vault_fetch_attribute_type(options) + + # Attribute API + attribute(attribute_name, attribute_type) + + # Getter + define_method(attribute_name) do + unless __vault_loaded_attributes.include?(attribute_name) + __vault_load_attribute!(attribute_name, self.class.__vault_attributes[attribute_name]) + end + + read_attribute(attribute_name) + end + + # Setter + define_method("#{attribute_name}=") do |value| + # Prevent the attribute from loading when a value is provided before + # the attribute is loaded from Vault but only if the model is initialized + __vault_loaded_attributes << attribute_name + + # Force the update of the attribute, to be consistent with old behaviour + cast_value = write_attribute(attribute_name, value) + + # Rails 4.2 resets the dirty state if write_attribute is called with the same value after attribute_will_change + attribute_will_change!(attribute_name) + + cast_value + end + + serializer = _vault_fetch_serializer(options, attribute_type) + + # Make a note of this attribute so we can use it in the future (maybe). + __vault_attributes[attribute_name.to_sym] = { + key: key, + path: path, + serializer: serializer, + encrypted_column: encrypted_column, + convergent: convergent + } + + self + end + + # Encrypt Vault attributes before saving them + def vault_persist_before_save! + skip_callback :save, :after, :__vault_persist_attributes! + before_save :__vault_encrypt_attributes! + end + + # Define proxy getter and setter methods + # + # Override the getter and setter for a particular non-encrypted attribute + # so that they also call the getter/setter of the encrypted one. + # This ensures that all the code that uses the attribute in question + # also updates/retrieves the encrypted value whenever it is available. + # + # This method is useful if you have a plaintext attribute that you want to replace with a vault attribute. + # During a transition period both attributes can be seamlessly read/changed at the same time. + # + # @param [String | Symbol] non_encrypted_attribute + # The name of original attribute (non-encrypted). + # @param [String | Symbol] encrypted_attribute + # The name of the encrypted attribute. + # This makes sure that the encrypted attribute behaves like a real AR attribute. + # @param [Boolean] (false) encrypted_attribute_only + # Whether to read and write to both encrypted and non-encrypted attributes. + # Useful for when we stop using the non-encrypted one. + def vault_attribute_proxy(non_encrypted_attribute, encrypted_attribute, options={}) + if options[:type].present? + ActiveSupport::Deprecation.warn('The `type` option on `vault_attribute_proxy` is now ignored. To specify type information you should move the `type` option onto the `vault_attribute` definition.') + end + # Only return the encrypted attribute if it's available and encrypted_attribute_only is true. + define_method(non_encrypted_attribute) do + return send(encrypted_attribute) if options[:encrypted_attribute_only] + + send(encrypted_attribute) || super() + end + + # Update only the encrypted attribute if encrypted_attribute_only is true and both attributes otherwise. + define_method("#{non_encrypted_attribute}=") do |value| + super(value) unless options[:encrypted_attribute_only] + + send("#{encrypted_attribute}=", value) + end + end + + # The list of Vault attributes. + # + # @return [Hash] + def __vault_attributes + @vault_attributes ||= {} + end + + # Validate that Vault options are all a-okay! This method will raise + # exceptions if something does not make sense. + def _vault_validate_options!(options) + if options[:serializer] + if options[:encode] || options[:decode] + raise Vault::Rails::ValidationFailedError, "Cannot use a " \ + "custom encoder/decoder if a `:serializer' is specified!" + end + end + + if options[:encode] && !options[:decode] + raise Vault::Rails::ValidationFailedError, "Cannot specify " \ + "`:encode' without specifying `:decode' as well!" + end + + if options[:decode] && !options[:encode] + raise Vault::Rails::ValidationFailedError, "Cannot specify " \ + "`:decode' without specifying `:encode' as well!" + end + end + + def _vault_fetch_attribute_type(options) + attribute_type = options.fetch(:type, ActiveRecord::Type::Value.new) + + if attribute_type.is_a?(Symbol) + adapter = ActiveRecord::Base.try(:connection_db_config).try(:adapter) || (ActiveRecord::Base.try(:connection_config) || {})[:adapter] + + if adapter + ActiveRecord::Type.lookup(attribute_type, adapter: adapter) + else + ActiveRecord::Type.lookup(attribute_type) # This call does a db connection, best find a way to configure the adapter + end + else + ActiveModel::Type::Value.new + end + rescue ArgumentError => e + if e.message =~ /Unknown type / + raise RuntimeError, "Unrecognized attribute type `#{attribute_type}`!" + else + raise + end + end + + def _vault_fetch_serializer(options, attribute_type) + if options[:serialize] + serializer = options[:serialize] + + # Unless a class or module was given, construct our serializer. (Slass + # is a subset of Module). + if serializer && !serializer.is_a?(Module) + Vault::Rails.serializer_for(serializer) + else + serializer + end + elsif options[:encode] && options[:decode] + # See if custom encoding or decoding options were given. + Class.new do + define_singleton_method(:encode, &options[:encode]) + define_singleton_method(:decode, &options[:decode]) + end + elsif attribute_type.is_a?(ActiveRecord::Type::Value) && attribute_type.type.present? + begin + Vault::Rails.serializer_for(attribute_type.type) + rescue Vault::Rails::Serializers::UnknownSerializerError + nil + end + end + end + + def vault_lazy_decrypt? + !!@vault_lazy_decrypt + end + + def vault_lazy_decrypt! + @vault_lazy_decrypt = true + end + + # works only with convergent encryption + def vault_persist_all(attribute, records, plaintexts, validate: true) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts, validate: validate) + end + + # works only with convergent encryption + # relevant only if lazy decryption is enabled + def vault_load_all(attribute, records) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + end + + def encrypt_value(attribute, value) + options = __vault_attributes[attribute] + + key = options[:key] + path = options[:path] + serializer = options[:serializer] + convergent = options[:convergent] + + # Apply the serializer to the value, if one exists + plaintext = serializer ? serializer.encode(value) : value + + Vault::Rails.encrypt(path, key, plaintext, Vault.client, convergent) + end + + def encrypted_find_by(attributes) + find_by(search_options(attributes)) + end + + def encrypted_find_by!(attributes) + find_by!(search_options(attributes)) + end + + def encrypted_where(attributes) + where(search_options(attributes)) + end + + def encrypted_where_not(attributes) + where.not(search_options(attributes)) + end + + private + + def search_options(attributes) + {}.tap do |search_options| + attributes.each do |attribute_name, attribute_value| + attribute_options = __vault_attributes[attribute_name] + encrypted_column = attribute_options[:encrypted_column] + + unless attribute_options[:convergent] + raise ArgumentError, 'You cannot search with non-convergent fields' + end + + search_options[encrypted_column] = encrypt_value(attribute_name, attribute_value) + end + end + end + end + + included do + # After a resource has been initialized, immediately communicate with + # Vault and decrypt any attributes unless vault_lazy_decrypt is set. + after_initialize :__vault_initialize_attributes! + + # After we save the record, persist all the values to Vault and reload + # them attributes from Vault to ensure we have the proper attributes set. + # The reason we use `after_save` here is because a `before_save` could + # run too early in the callback process. If a user is changing Vault + # attributes in a callback, it is possible that our callback will run + # before theirs, resulting in attributes that are not persisted. + after_save :__vault_persist_attributes! + + def __vault_loaded_attributes + @__vault_loaded_attributes ||= Set.new + end + + def __vault_initialize_attributes! + return if self.class.vault_lazy_decrypt? + + __vault_load_attributes! + end + + # Decrypt all the attributes from Vault. + def __vault_load_attributes! + self.class.__vault_attributes.each do |attribute, options| + self.__vault_load_attribute!(attribute, options) + end + end + + # Decrypt and load a single attribute from Vault. + def __vault_load_attribute!(attribute, options) + # If the user provided a value for the attribute, do not try to load it from Vault + return if __vault_loaded_attributes.include?(attribute) + + key = options[:key] + path = options[:path] + serializer = options[:serializer] + column = options[:encrypted_column] + convergent = options[:convergent] + + # Load the ciphertext + ciphertext = read_attribute(column) + + # Load the plaintext value + plaintext = Vault::Rails.decrypt(path, key, ciphertext, Vault.client, convergent) + + # Deserialize the plaintext value, if a serializer exists + plaintext = serializer.decode(plaintext) if serializer + + __vault_loaded_attributes << attribute + + # Write the virtual attribute with the plaintext value + write_attribute(attribute, plaintext).tap { clear_attribute_changes([attribute]) } + end + + # Encrypt all the attributes using Vault and set the encrypted values back + # on this model. + # @return [true] + def __vault_persist_attributes! + changes = __vault_encrypt_attributes!(in_after_save: true) + + # If there are any changes to the model, update them all at once, + # skipping any callbacks and validation. This is okay, because we are + # already in a transaction due to the callback. + self.update_columns(changes) unless changes.empty? + + true + end + + def __vault_encrypt_attributes!(in_after_save: false) + changes = {} + + self.class.__vault_attributes.each do |attribute, options| + if c = self.__vault_encrypt_attribute!(attribute, options, in_after_save: in_after_save) + changes.merge!(c) + end + end + + changes + end + + # Encrypt a single attribute using Vault and persist back onto the + # encrypted attribute value. + def __vault_encrypt_attribute!(attribute, options, in_after_save: false) + # Only persist changed attributes to minimize requests - this helps + # minimize the number of requests to Vault. + + if in_after_save && ActiveRecord.version >= Gem::Version.new('5.1.0') + # ActiveRecord 5.2 changes the behaviour of `changed` in `after_*` callbacks + # https://www.ombulabs.com/blog/rails/upgrades/active-record-5-1-api-changes.html + # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-saved_change_to_attribute + return unless saved_change_to_attribute?(attribute) + else + # Rails >= 4.2.8 and < 5.1 + return unless changed.include?("#{attribute}") + end + + column = options[:encrypted_column] + + # Get the current value of the plaintext attribute + plaintext = read_attribute(attribute) + + # Generate the ciphertext and store it back as an attribute + ciphertext = self.class.encrypt_value(attribute, plaintext) + + # Write the attribute back, so that we don't have to reload the record + # to get the ciphertext + write_attribute(column, ciphertext) + + # Return the updated column so we can save + { column => ciphertext } + end + + def unencrypted_attributes + encrypted_attributes = self.class.__vault_attributes.values.map {|x| x[:encrypted_column].to_s } + attributes.delete_if { |attribute| encrypted_attributes.include?(attribute) } + end + + # Override the reload method to reload the Vault attributes. This will + # ensure that we always have the most recent data from Vault when we + # reload a record from the database. + def reload(*) + super.tap do + # Unset all attributes to force the new data to be pulled from Vault + self.class.__vault_attributes.each do |attribute, _| + write_attribute(attribute, nil) + end + + __vault_loaded_attributes.clear + + __vault_initialize_attributes! + clear_changes_information + end + end + end + end + end +end diff --git a/lib/vault/legacy/encrypted_model.rb b/lib/vault/legacy/encrypted_model.rb new file mode 100644 index 00000000..05b579f5 --- /dev/null +++ b/lib/vault/legacy/encrypted_model.rb @@ -0,0 +1,405 @@ +require "active_support/concern" + +module Vault + module Legacy + module EncryptedModel + extend ActiveSupport::Concern + + module ClassMethods + # Creates an attribute that is read and written using Vault. + # + # @example + # + # class Person < ActiveRecord::Base + # include Vault::EncryptedModel + # vault_attribute :ssn + # end + # + # person = Person.new + # person.ssn = "123-45-6789" + # person.save + # person.encrypted_ssn #=> "vault:v0:6hdPkhvyL6..." + # + # @param [Symbol] column + # the column that is encrypted + # @param [Hash] options + # + # @option options [Symbol] :encrypted_column + # the name of the encrypted column (default: +#{column}_encrypted+) + # @option options [Bool] :convergent + # use convergent encryption (default: +false+) + # @option options [String] :path + # the path to the transit backend (default: +transit+) + # @option options [String] :key + # the name of the encryption key (default: +#{app}_#{table}_#{column}+) + # @option options [Symbol, Class] :serializer + # the name of the serializer to use (or a class) + # @option options [Proc] :encode + # a proc to encode the value with + # @option options [Proc] :decode + # a proc to decode the value with + def vault_attribute(attribute, options = {}) + encrypted_column = options[:encrypted_column] || "#{attribute}_encrypted" + path = options[:path] || "transit" + key = options[:key] || "#{Vault::Rails.application}_#{table_name}_#{attribute}" + convergent = options.fetch(:convergent, false) + + # Sanity check options! + _vault_validate_options!(options) + + # Get the serializer if one was given. + serializer = options[:serialize] + + # Unless a class or module was given, construct our serializer. (Slass + # is a subset of Module). + if serializer && !serializer.is_a?(Module) + serializer = Vault::Rails.serializer_for(serializer) + end + + # See if custom encoding or decoding options were given. + if options[:encode] && options[:decode] + serializer = Class.new + serializer.define_singleton_method(:encode, &options[:encode]) + serializer.define_singleton_method(:decode, &options[:decode]) + end + + unless serializer + serializer = Vault::Rails::Serializers::StringSerializer + end + + # Getter + define_method("#{attribute}") do + if instance_variable_defined?("@#{attribute}") + return instance_variable_get("@#{attribute}") + end + + __vault_load_attribute!(attribute, self.class.__vault_attributes[attribute]) + end + + # Setter + define_method("#{attribute}=") do |value| + cast_value = cast_value_to_type(options, value) + # We always set it as changed without comparing with the current value + # because we allow our held values to be mutated, so we need to assume + # that if you call attr=, you want it send back regardless. + attribute_will_change!("#{attribute}") + instance_variable_set("@#{attribute}", cast_value) + end + + # Checker + define_method("#{attribute}?") do + send("#{attribute}").present? + end + + # Dirty method + define_method("#{attribute}_change") do + changes["#{attribute}"] + end + + # Dirty method + define_method("#{attribute}_changed?") do + changed.include?("#{attribute}") + end + + # Dirty method + define_method("#{attribute}_was") do + if changes["#{attribute}"] + changes["#{attribute}"][0] + else + public_send("#{attribute}") + end + end + + # Make a note of this attribute so we can use it in the future (maybe). + __vault_attributes[attribute.to_sym] = { + key: key, + path: path, + serializer: serializer, + encrypted_column: encrypted_column, + convergent: convergent + } + + self + end + + # Encrypt Vault attributes before saving them + def vault_persist_before_save! + skip_callback :save, :after, :__vault_persist_attributes! + before_save :__vault_encrypt_attributes! + end + + # Define proxy getter and setter methods + # + # Override the getter and setter for a particular non-encrypted attribute + # so that they also call the getter/setter of the encrypted one. + # This ensures that all the code that uses the attribute in question + # also updates/retrieves the encrypted value whenever it is available. + # + # This method is useful if you have a plaintext attribute that you want to replace with a vault attribute. + # During a transition period both attributes can be seamlessly read/changed at the same time. + # + # @param [String | Symbol] non_encrypted_attribute + # The name of original attribute (non-encrypted). + # @param [String | Symbol] encrypted_attribute + # The name of the encrypted attribute. + # This makes sure that the encrypted attribute behaves like a real AR attribute. + # @param [Boolean] (false) encrypted_attribute_only + # Whether to read and write to both encrypted and non-encrypted attributes. + # Useful for when we stop using the non-encrypted one. + def vault_attribute_proxy(non_encrypted_attribute, encrypted_attribute, options={}) + if options[:type].present? + ActiveSupport::Deprecation.warn('The `type` option on `vault_attribute_proxy` is now ignored. To specify type information you should move the `type` option onto the `vault_attribute` definition.') + end + # Only return the encrypted attribute if it's available and encrypted_attribute_only is true. + define_method(non_encrypted_attribute) do + return send(encrypted_attribute) if options[:encrypted_attribute_only] + + send(encrypted_attribute) || super() + end + + # Update only the encrypted attribute if encrypted_attribute_only is true and both attributes otherwise. + define_method("#{non_encrypted_attribute}=") do |value| + super(value) unless options[:encrypted_attribute_only] + + send("#{encrypted_attribute}=", value) + end + end + + # The list of Vault attributes. + # + # @return [Hash] + def __vault_attributes + @vault_attributes ||= {} + end + + # Validate that Vault options are all a-okay! This method will raise + # exceptions if something does not make sense. + def _vault_validate_options!(options) + if options[:serializer] + if options[:encode] || options[:decode] + raise Vault::Rails::ValidationFailedError, "Cannot use a " \ + "custom encoder/decoder if a `:serializer' is specified!" + end + end + + if options[:encode] && !options[:decode] + raise Vault::Rails::ValidationFailedError, "Cannot specify " \ + "`:encode' without specifying `:decode' as well!" + end + + if options[:decode] && !options[:encode] + raise Vault::Rails::ValidationFailedError, "Cannot specify " \ + "`:decode' without specifying `:encode' as well!" + end + end + + def vault_lazy_decrypt? + !!@vault_lazy_decrypt + end + + def vault_lazy_decrypt! + @vault_lazy_decrypt = true + end + + # works only with convergent encryption + def vault_persist_all(attribute, records, plaintexts, validate: true) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts, validate: validate) + end + + # works only with convergent encryption + # relevant only if lazy decryption is enabled + def vault_load_all(attribute, records) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + end + + def encrypt_value(attribute, value) + options = __vault_attributes[attribute] + + key = options[:key] + path = options[:path] + serializer = options[:serializer] + convergent = options[:convergent] + + plaintext = serializer.encode(value) + + Vault::Rails.encrypt(path, key, plaintext, Vault.client, convergent) + end + + def encrypted_find_by(attributes) + find_by(search_options(attributes)) + end + + def encrypted_find_by!(attributes) + find_by!(search_options(attributes)) + end + + def encrypted_where(attributes) + where(search_options(attributes)) + end + + def encrypted_where_not(attributes) + where.not(search_options(attributes)) + end + + private + + def search_options(attributes) + {}.tap do |search_options| + attributes.each do |attribute_name, attribute_value| + attribute_options = __vault_attributes[attribute_name] + encrypted_column = attribute_options[:encrypted_column] + + unless attribute_options[:convergent] + raise ArgumentError, 'You cannot search with non-convergent fields' + end + + search_options[encrypted_column] = encrypt_value(attribute_name, attribute_value) + end + end + end + end + + included do + # After a resource has been initialized, immediately communicate with + # Vault and decrypt any attributes unless vault_lazy_decrypt is set. + after_initialize :__vault_load_attributes! + + # After we save the record, persist all the values to Vault and reload + # them attributes from Vault to ensure we have the proper attributes set. + # The reason we use `after_save` here is because a `before_save` could + # run too early in the callback process. If a user is changing Vault + # attributes in a callback, it is possible that our callback will run + # before theirs, resulting in attributes that are not persisted. + after_save :__vault_persist_attributes! + + # Decrypt all the attributes from Vault. + # @return [true] + def __vault_load_attributes! + return if self.class.vault_lazy_decrypt? + + self.class.__vault_attributes.each do |attribute, options| + self.__vault_load_attribute!(attribute, options) + end + end + + def attributes + super.tap do |attrs| + missing_keys = self.class.__vault_attributes.keys.map(&:to_s) - attrs.keys + missing_keys.each do |key| + attrs.store(key, public_send(key)) + end + end + end + + # Decrypt and load a single attribute from Vault. + def __vault_load_attribute!(attribute, options) + key = options[:key] + path = options[:path] + serializer = options[:serializer] + column = options[:encrypted_column] + convergent = options[:convergent] + + # Load the ciphertext + ciphertext = read_attribute(column) + + # If the user provided a value for the attribute, do not try to load it from Vault + return if instance_variable_get("@#{attribute}") + + # Load the plaintext value + plaintext = Vault::Rails.decrypt(path, key, ciphertext, Vault.client, convergent) + + # Deserialize the plaintext value, if a serializer exists + plaintext = serializer.decode(plaintext) if serializer + + # Write the virtual attribute with the plaintext value + instance_variable_set("@#{attribute}", plaintext) + end + + def cast_value_to_type(options, value) + type_constant_name = options.fetch(:type, :value).to_s.camelize + type = ActiveRecord::Type.const_get(type_constant_name).new + + if type.respond_to?(:type_cast_from_user) + type.type_cast_from_user(value) + else + value + end + end + + # Encrypt all the attributes using Vault and set the encrypted values back + # on this model. + # @return [true] + def __vault_persist_attributes! + changes = __vault_encrypt_attributes! + + # If there are any changes to the model, update them all at once, + # skipping any callbacks and validation. This is okay, because we are + # already in a transaction due to the callback. + self.update_columns(changes) if !changes.empty? + + true + end + + def __vault_encrypt_attributes! + changes = {} + + self.class.__vault_attributes.each do |attribute, options| + if c = self.__vault_encrypt_attribute!(attribute, options) + changes.merge!(c) + end + end + + changes + end + + # Encrypt a single attribute using Vault and persist back onto the + # encrypted attribute value. + def __vault_encrypt_attribute!(attribute, options) + # Only persist changed attributes to minimize requests - this helps + # minimize the number of requests to Vault. + return unless changed.include?("#{attribute}") + + column = options[:encrypted_column] + + # Get the current value of the plaintext attribute + plaintext = instance_variable_get("@#{attribute}") + + # Generate the ciphertext and store it back as an attribute + ciphertext = self.class.encrypt_value(attribute, plaintext) + + # Write the attribute back, so that we don't have to reload the record + # to get the ciphertext + write_attribute(column, ciphertext) + + # Return the updated column so we can save + { column => ciphertext } + end + + def unencrypted_attributes + encrypted_attributes = self.class.__vault_attributes.values.map {|x| x[:encrypted_column].to_s } + attributes.delete_if { |attribute| encrypted_attributes.include?(attribute) } + end + + # Override the reload method to reload the Vault attributes. This will + # ensure that we always have the most recent data from Vault when we + # reload a record from the database. + def reload(*) + super.tap do + # Unset all the instance variables to force the new data to be pulled from Vault + self.class.__vault_attributes.each do |attribute, _| + if instance_variable_defined?("@#{attribute}") + self.remove_instance_variable("@#{attribute}") + end + end + + self.__vault_load_attributes! + end + end + end + end + end +end diff --git a/lib/vault/perform_in_batches.rb b/lib/vault/perform_in_batches.rb new file mode 100644 index 00000000..14750b41 --- /dev/null +++ b/lib/vault/perform_in_batches.rb @@ -0,0 +1,61 @@ +module Vault + class PerformInBatches + def initialize(attribute, options) + @attribute = attribute + + @key = options[:key] + @path = options[:path] + @serializer = options[:serializer] + @column = options[:encrypted_column] + @convergent = options[:convergent] + end + + def encrypt(records, plaintexts, validate: true) + raise 'Batch Operations work only with convergent attributes' unless @convergent + + raw_plaintexts = serialize(plaintexts) + + ciphertexts = Vault::Rails.batch_encrypt(path, key, raw_plaintexts, Vault.client) + + records.each_with_index do |record, index| + record.send("#{column}=", ciphertexts[index]) + record.save(validate: validate) + end + end + + def decrypt(records) + raise 'Batch Operations work only with convergent attributes' unless @convergent + + ciphertexts = records.map { |record| record.send(column) } + + raw_plaintexts = Vault::Rails.batch_decrypt(path, key, ciphertexts, Vault.client) + plaintexts = deserialize(raw_plaintexts) + + records.each_with_index do |record, index| + if Vault::Rails.latest? + record.__vault_loaded_attributes << attribute + + record.write_attribute(attribute, plaintexts[index]) + else + record.instance_variable_set("@#{attribute}", plaintexts[index]) + end + end + end + + private + + attr_reader :key, :path, :serializer, :column, :attribute + + def serialize(plaintexts) + return plaintexts unless serializer + + plaintexts.map { |plaintext| serializer.encode(plaintext) } + end + + def deserialize(plaintexts) + return plaintexts unless serializer + + plaintexts.map { |plaintext| serializer.decode(plaintext) } + end + end +end diff --git a/lib/vault/rails.rb b/lib/vault/rails.rb index 032e71be..e56af840 100644 --- a/lib/vault/rails.rb +++ b/lib/vault/rails.rb @@ -1,13 +1,23 @@ -require "vault" - -require "base64" -require "json" - -require_relative "encrypted_model" -require_relative "rails/configurable" -require_relative "rails/errors" -require_relative "rails/serializer" -require_relative "rails/version" +require 'vault' + +require 'base64' +require 'json' + +require_relative 'rails/version' +require_relative 'encrypted_model' +require_relative 'attribute_proxy' +require_relative 'perform_in_batches' +require_relative 'rails/configurable' +require_relative 'rails/errors' +require_relative 'rails/vault_uniqueness_validator' +require_relative 'rails/serializers/json_serializer' +require_relative 'rails/serializers/date_serializer' +require_relative 'rails/serializers/integer_serializer' +require_relative 'rails/serializers/float_serializer' +require_relative 'rails/serializers/time_serializer' +require_relative 'rails/serializers/date_time_serializer' +require_relative 'rails/serializers/ipaddr_serializer' +require_relative 'rails/serializers/string_serializer' module Vault module Rails @@ -15,14 +25,18 @@ module Rails # # @return [Hash] SERIALIZERS = { - json: Vault::Rails::JSONSerializer, + json: Vault::Rails::Serializers::JSONSerializer, + date: Vault::Rails::Serializers::DateSerializer, + integer: Vault::Rails::Serializers::IntegerSerializer, + float: Vault::Rails::Serializers::FloatSerializer, + time: Vault::Rails::Serializers::TimeSerializer, + datetime: Vault::Rails::Serializers::DateTimeSerializer, + ipaddr: Vault::Rails::Serializers::IPAddrSerializer, + inet: Vault::Rails::Serializers::IPAddrSerializer, + cidr: Vault::Rails::Serializers::IPAddrSerializer, + string: Vault::Rails::Serializers::StringSerializer }.freeze - # The default encoding. - # - # @return [String] - DEFAULT_ENCODING = "utf-8".freeze - # The warning string to print when running in development mode. DEV_WARNING = "[vault-rails] Using in-memory cipher - this is not secure " \ "and should never be used in production-like environments!".freeze @@ -69,28 +83,46 @@ def respond_to_missing?(m, include_private = false) # the plaintext to encrypt # @param [Vault::Client] client # the Vault client to use + # @param [Bool] convergent + # use convergent encryption # # @return [String] # the encrypted cipher text - def encrypt(path, key, plaintext, client = self.client) - if plaintext.blank? - return plaintext - end + def encrypt(path, key, plaintext, client = self.client, convergent = false) + return plaintext if plaintext.blank? - path = path.to_s if !path.is_a?(String) - key = key.to_s if !key.is_a?(String) + path = path.to_s + key = key.to_s with_retries do if self.enabled? - result = self.vault_encrypt(path, key, plaintext, client) + result = self.vault_encrypt(path, key, plaintext, client, convergent) else - result = self.memory_encrypt(path, key, plaintext, client) + result = self.memory_encrypt(path, key, plaintext, client, convergent) end return self.force_encoding(result) end end + # works only with convergent encryption + def batch_encrypt(path, key, plaintexts, client = self.client) + return [] if plaintexts.empty? + + path = path.to_s + key = key.to_s + + with_retries do + results = if self.enabled? + self.vault_batch_encrypt(path, key, plaintexts, client) + else + self.memory_batch_encrypt(path, key, plaintexts, client) + end + + results.map { |result| self.force_encoding(result) } + end + end + # Decrypt the given ciphertext data using the provided mount and key. # # @param [String] path @@ -104,25 +136,43 @@ def encrypt(path, key, plaintext, client = self.client) # # @return [String] # the decrypted plaintext text - def decrypt(path, key, ciphertext, client = self.client) + def decrypt(path, key, ciphertext, client = self.client, convergent = false) if ciphertext.blank? return ciphertext end - path = path.to_s if !path.is_a?(String) - key = key.to_s if !key.is_a?(String) + path = path.to_s + key = key.to_s with_retries do if self.enabled? - result = self.vault_decrypt(path, key, ciphertext, client) + result = self.vault_decrypt(path, key, ciphertext, client, convergent) else - result = self.memory_decrypt(path, key, ciphertext, client) + result = self.memory_decrypt(path, key, ciphertext, client, convergent) end return self.force_encoding(result) end end + # works only with convergent encryption + def batch_decrypt(path, key, ciphertexts, client = self.client) + return [] if ciphertexts.empty? + + path = path.to_s + key = key.to_s + + with_retries do + results = if self.enabled? + self.vault_batch_decrypt(path, key, ciphertexts, client) + else + self.memory_batch_decrypt(path, key, ciphertexts, client) + end + + results.map { |result| self.force_encoding(result) } + end + end + # Get the serializer that corresponds to the given key. If the key does not # correspond to a known serializer, an exception will be raised. # @@ -136,14 +186,14 @@ def serializer_for(key) if serializer = SERIALIZERS[key] return serializer else - raise Vault::Rails::UnknownSerializerError.new(key) + raise Vault::Rails::Serializers::UnknownSerializerError.new(key) end end protected # Perform in-memory encryption. This is useful for testing and development. - def memory_encrypt(path, key, plaintext, client) + def memory_encrypt(path, key, plaintext, _client, convergent) log_warning(DEV_WARNING) if self.in_memory_warnings_enabled? return nil if plaintext.nil? @@ -151,41 +201,139 @@ def memory_encrypt(path, key, plaintext, client) cipher = OpenSSL::Cipher::AES.new(128, :CBC) cipher.encrypt cipher.key = memory_key_for(path, key) - return Base64.strict_encode64(cipher.update(plaintext) + cipher.final) + + iv = if convergent + cipher.iv = Vault::Rails.convergent_encryption_context.first(16) + else + cipher.random_iv + end + + Base64.strict_encode64(iv + cipher.update(plaintext) + cipher.final) + end + + # Perform in-memory encryption. This is useful for testing and development. + def memory_batch_encrypt(path, key, plaintexts, _client) + plaintexts.map { |plaintext| memory_encrypt(path, key, plaintext, _client, true) } end # Perform in-memory decryption. This is useful for testing and development. - def memory_decrypt(path, key, ciphertext, client) + def memory_decrypt(path, key, ciphertext, _client, convergent) log_warning(DEV_WARNING) if self.in_memory_warnings_enabled? - return nil if ciphertext.nil? + return ciphertext if ciphertext.blank? cipher = OpenSSL::Cipher::AES.new(128, :CBC) cipher.decrypt cipher.key = memory_key_for(path, key) - return cipher.update(Base64.strict_decode64(ciphertext)) + cipher.final + + ciphertext_bytes = Base64.strict_decode64(ciphertext) + + cipher.iv = ciphertext_bytes.first(16) + ciphertext = ciphertext_bytes[16..-1] + + cipher.update(ciphertext) + cipher.final end + def memory_batch_decrypt(path, key, ciphertexts, _client) + ciphertexts.map { |ciphertext| memory_decrypt(path, key, ciphertext, _client, true) } + end + + # Perform encryption using Vault. This will raise exceptions if Vault is # unavailable. - def vault_encrypt(path, key, plaintext, client) + def vault_encrypt(path, key, plaintext, client, convergent) return nil if plaintext.nil? - route = File.join(path, "encrypt", key) - secret = client.logical.write(route, - plaintext: Base64.strict_encode64(plaintext), - ) - return secret.data[:ciphertext] + route = File.join(path, 'encrypt', key) + options = { + plaintext: Base64.strict_encode64(plaintext) + } + + if convergent + options.merge!( + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + convergent_encryption: true, + derived: true + ) + end + + secret = client.logical.write(route, options) + secret.data[:ciphertext] + end + + def vault_batch_encrypt(path, key, plaintexts, client) + return [] if plaintexts.empty? + + # Only present values can be encrypted by Vault. Empty values should be returned as they are. + non_empty_plaintexts = plaintexts.select { |plaintext| plaintext.present? } + return plaintexts if non_empty_plaintexts.empty? # nothing to encrypt + + route = File.join(path, 'encrypt', key) + + options = { + convergent_encryption: true, + derived: true + } + + batch_input = non_empty_plaintexts.map do |plaintext| + { + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + plaintext: Base64.strict_encode64(plaintext) + } + end + + options.merge!(batch_input: batch_input) + + secret = client.logical.write(route, options) + vault_results = secret.data[:batch_results].map { |result| result[:ciphertext] } + + plaintexts.map do |plaintext| + plaintext.present? ? vault_results.shift : plaintext + end end # Perform decryption using Vault. This will raise exceptions if Vault is # unavailable. - def vault_decrypt(path, key, ciphertext, client) + def vault_decrypt(path, key, ciphertext, client, convergent) return nil if ciphertext.nil? - route = File.join(path, "decrypt", key) - secret = client.logical.write(route, ciphertext: ciphertext) - return Base64.strict_decode64(secret.data[:plaintext]) + options = { ciphertext: ciphertext } + + if convergent + options.merge!( + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context) + ) + end + + route = File.join(path, 'decrypt', key) + secret = client.logical.write(route, options) + + Base64.strict_decode64(secret.data[:plaintext]) + end + + def vault_batch_decrypt(path, key, ciphertexts, client) + return [] if ciphertexts.empty? + + # Only present values can be decrypted by Vault. Empty values should be returned as they are. + non_empty_ciphertexts = ciphertexts.select { |ciphertext| ciphertext.present? } + return ciphertexts if non_empty_ciphertexts.empty? + + route = File.join(path, 'decrypt', key) + + batch_input = non_empty_ciphertexts.map do |ciphertext| + { + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + ciphertext: ciphertext + } + end + options = { batch_input: batch_input } + + secret = client.logical.write(route, options) + vault_results = secret.data[:batch_results].map { |result| Base64.strict_decode64(result[:plaintext]) } + + ciphertexts.map do |ciphertext| + ciphertext.present? ? vault_results.shift : ciphertext + end end # The symmetric key for the given params. @@ -194,12 +342,11 @@ def memory_key_for(path, key) return Base64.strict_encode64("#{path}/#{key}".ljust(16, "x")).byteslice(0..15) end - # Forces the encoding into the default Rails encoding and returns the + # Forces the encoding into the default encoding and returns the # newly encoded string. # @return [String] def force_encoding(str) - encoding = ::Rails.application.config.encoding || DEFAULT_ENCODING - str.force_encoding(encoding).encode(encoding) + str.blank? ? str : str.force_encoding(Vault::Rails.encoding).encode(Vault::Rails.encoding) end private @@ -223,9 +370,7 @@ def with_retries(client = self.client, &block) end def log_warning(msg) - if defined?(::Rails) && ::Rails.logger != nil - ::Rails.logger.warn { msg } - end + ::ActiveRecord::Base.logger.warn { msg } if ::ActiveRecord::Base.logger end end end diff --git a/lib/vault/rails/configurable.rb b/lib/vault/rails/configurable.rb index 50408df4..977a2441 100644 --- a/lib/vault/rails/configurable.rb +++ b/lib/vault/rails/configurable.rb @@ -3,6 +3,13 @@ module Rails module Configurable include Vault::Configurable + # The default encoding, used if Vault::Rails.encoding is not set, + # and we're not in a rails app so can't use the default encoding for + # the rails app (via Rails.application.config.encoding) + # + # @return [String] + DEFAULT_ENCODING = Encoding::UTF_8 + # The name of the Vault::Rails application. # # @raise [RuntimeError] @@ -119,6 +126,48 @@ def retry_max_wait def retry_max_wait=(val) @retry_max_wait = val end + + # The convergent encryption context for deriving an + # encryption key when using convergent encryption. + # + # @raise [RuntimeError] + # if the convergent encryption context has not been set + # + # @return [String] + def convergent_encryption_context + if !defined?(@convergent_encryption_context) || @convergent_encryption_context.nil? + raise RuntimeError, "Must set `Vault::Rails.convergent_encryption_context'!" + end + + @convergent_encryption_context + end + + # Sets the convergent encryption context for use with convergent encryption + def convergent_encryption_context=(context) + @convergent_encryption_context = context + end + + # The encoding to be used when decrypting values. Defaults to + # UTF-8 if not explicitly set. + # + # @return [String] + def encoding + @encoding ||= default_encoding + end + + # Set the encoding to be used when decrypting values. + # + # @param [String] new_encoding + def encoding=(new_encoding) + @encoding = Encoding.find(new_encoding) + end + + private + + def default_encoding + default_encoding = ::Rails.application.config.encoding if defined?(::Rails) + Encoding.find(default_encoding || DEFAULT_ENCODING) + end end end end diff --git a/lib/vault/rails/errors.rb b/lib/vault/rails/errors.rb index 73fe0d62..8ed46963 100644 --- a/lib/vault/rails/errors.rb +++ b/lib/vault/rails/errors.rb @@ -1,19 +1,20 @@ module Vault module Rails class VaultRailsError < RuntimeError; end + class ValidationFailedError < VaultRailsError; end - class UnknownSerializerError < VaultRailsError - def initialize(key) - super <<-EOH - Unknown Vault serializer `:#{key}'. Valid serializers are: + module Serializers + class UnknownSerializerError < VaultRailsError + def initialize(key) + super <<-EOH + Unknown Vault serializer `:#{key}`. Valid serializers are: - #{SERIALIZERS.keys.sort.map(&:inspect).join(", ")} + #{SERIALIZERS.keys.sort.map(&:inspect).join(", ")} Please refer to the documentation for more examples. - EOH + EOH + end end end - - class ValidationFailedError < VaultRailsError; end end end diff --git a/lib/vault/rails/serializer.rb b/lib/vault/rails/serializer.rb deleted file mode 100644 index 26f7114e..00000000 --- a/lib/vault/rails/serializer.rb +++ /dev/null @@ -1,33 +0,0 @@ -module Vault - module Rails - module JSONSerializer - DECODE_OPTIONS = { - max_nested: false, - create_additions: false, - }.freeze - - def self.encode(raw) - self._init! - - raw = {} if raw.nil? - - JSON.fast_generate(raw) - end - - def self.decode(raw) - self._init! - - return {} if raw.nil? || raw.empty? - JSON.parse(raw, DECODE_OPTIONS) - end - - protected - - def self._init! - return if defined?(@_init) - require "json" - @_init = true - end - end - end -end diff --git a/lib/vault/rails/serializers/date_serializer.rb b/lib/vault/rails/serializers/date_serializer.rb new file mode 100644 index 00000000..bfb750b2 --- /dev/null +++ b/lib/vault/rails/serializers/date_serializer.rb @@ -0,0 +1,22 @@ +module Vault + module Rails + module Serializers + # Converts date objects to and from ISO 8601 format (%F) + module DateSerializer + module_function + + def encode(raw) + return nil if raw.blank? + + raw = Date.parse(raw) if raw.is_a? String + raw.strftime('%F') + end + + def decode(raw) + return nil if raw.blank? + Date.strptime(raw, '%F') + end + end + end + end +end diff --git a/lib/vault/rails/serializers/date_time_serializer.rb b/lib/vault/rails/serializers/date_time_serializer.rb new file mode 100644 index 00000000..e3187165 --- /dev/null +++ b/lib/vault/rails/serializers/date_time_serializer.rb @@ -0,0 +1,17 @@ +module Vault + module Rails + module Serializers + # Converts datetime objects to and from ISO 8601 format with 3 + # fractional seconds + module DateTimeSerializer + include TimeSerializer + module_function :encode, :decode + + def decode(raw) + time = super + time.present? ? time.to_datetime : time + end + end + end + end +end diff --git a/lib/vault/rails/serializers/float_serializer.rb b/lib/vault/rails/serializers/float_serializer.rb new file mode 100644 index 00000000..f87a3443 --- /dev/null +++ b/lib/vault/rails/serializers/float_serializer.rb @@ -0,0 +1,21 @@ +module Vault + module Rails + module Serializers + module FloatSerializer + module_function + + def encode(raw) + return nil if raw.blank? + raw.to_s + end + + def decode(raw) + return nil if raw.blank? + raw.to_f + end + end + end + end +end + + diff --git a/lib/vault/rails/serializers/integer_serializer.rb b/lib/vault/rails/serializers/integer_serializer.rb new file mode 100644 index 00000000..16227de2 --- /dev/null +++ b/lib/vault/rails/serializers/integer_serializer.rb @@ -0,0 +1,19 @@ +module Vault + module Rails + module Serializers + module IntegerSerializer + module_function + + def encode(raw) + return nil if raw.blank? + raw.to_s + end + + def decode(raw) + return nil if raw.blank? + raw.to_i + end + end + end + end +end diff --git a/lib/vault/rails/serializers/ipaddr_serializer.rb b/lib/vault/rails/serializers/ipaddr_serializer.rb new file mode 100644 index 00000000..1d4a89de --- /dev/null +++ b/lib/vault/rails/serializers/ipaddr_serializer.rb @@ -0,0 +1,21 @@ +module Vault + module Rails + module Serializers + # Converts IPAddr objects to and from strings + module IPAddrSerializer + module_function + + def encode(ip_addr) + return nil if ip_addr.blank? + + "#{ip_addr}/#{ip_addr.instance_variable_get(:@mask_addr).to_s(2).count('1')}" + end + + def decode(string) + return nil if string.blank? + IPAddr.new(string) + end + end + end + end +end diff --git a/lib/vault/rails/serializers/json_serializer.rb b/lib/vault/rails/serializers/json_serializer.rb new file mode 100644 index 00000000..7171ca79 --- /dev/null +++ b/lib/vault/rails/serializers/json_serializer.rb @@ -0,0 +1,26 @@ +require "json" + +module Vault + module Rails + module Serializers + module JSONSerializer + DECODE_OPTIONS = { + max_nested: false, + create_additions: false, + }.freeze + + def self.encode(raw) + return if raw.nil? + return raw if raw.is_a?(String) + JSON.fast_generate(raw) + end + + + def self.decode(raw) + return if raw.nil? + JSON.parse(raw, DECODE_OPTIONS) + end + end + end + end +end diff --git a/lib/vault/rails/serializers/string_serializer.rb b/lib/vault/rails/serializers/string_serializer.rb new file mode 100644 index 00000000..1c914ff8 --- /dev/null +++ b/lib/vault/rails/serializers/string_serializer.rb @@ -0,0 +1,17 @@ +module Vault + module Rails + module Serializers + module StringSerializer + module_function + + def encode(value) + value.blank? ? value : value.to_s + end + + def decode(value) + value + end + end + end + end +end diff --git a/lib/vault/rails/serializers/time_serializer.rb b/lib/vault/rails/serializers/time_serializer.rb new file mode 100644 index 00000000..5297d278 --- /dev/null +++ b/lib/vault/rails/serializers/time_serializer.rb @@ -0,0 +1,23 @@ +module Vault + module Rails + module Serializers + # Converts time objects to and from ISO 8601 format with 3 + # fractional seconds + module TimeSerializer + module_function + + def encode(raw) + return nil if raw.blank? + + raw = Time.parse(raw) if raw.is_a? String + raw.iso8601(3) + end + + def decode(raw) + return nil if raw.blank? + Time.iso8601(raw) + end + end + end + end +end diff --git a/lib/vault/rails/vault_uniqueness_validator.rb b/lib/vault/rails/vault_uniqueness_validator.rb new file mode 100644 index 00000000..828a5e32 --- /dev/null +++ b/lib/vault/rails/vault_uniqueness_validator.rb @@ -0,0 +1,32 @@ +require 'active_record' + +class VaultUniquenessValidator < ActiveRecord::Validations::UniquenessValidator + def validate_each(record, attribute, value) + attribute_options = vault_options(record, attribute) + + unless attribute_options[:convergent] + raise 'You cannot check uniqueness of an attribute that is not convergently encrypted' + end + + encrypted_column = attribute_options[:encrypted_column] + + encrypted_value = record.class.encrypt_value(attribute, value) + + super(record, encrypted_column, encrypted_value) + + cleanse_error_message(record, attribute, encrypted_column, encrypted_value) + end + + private + + def vault_options(record, attribute) + record.class.__vault_attributes[attribute] + end + + def cleanse_error_message(record, attribute, encrypted_column, encrypted_value) + return unless record.errors.key?(encrypted_column.to_sym) + + record.errors.delete(encrypted_column.to_sym) + record.errors.add(attribute, :taken, value: encrypted_value) + end +end diff --git a/lib/vault/rails/version.rb b/lib/vault/rails/version.rb index dfce187a..ebd63e13 100644 --- a/lib/vault/rails/version.rb +++ b/lib/vault/rails/version.rb @@ -1,5 +1,9 @@ module Vault module Rails - VERSION = "0.4.0" + VERSION = '2.2.0' + + def self.latest? + ActiveRecord.version >= Gem::Version.new('5.0.0') + end end end diff --git a/lib/vault/transit_json_codec.rb b/lib/vault/transit_json_codec.rb new file mode 100644 index 00000000..869ec024 --- /dev/null +++ b/lib/vault/transit_json_codec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +require 'oj' + +module Vault + class TransitJsonCodec + def initialize(key) + @key = key + end + + def encrypt(plaintext) + return if plaintext.blank? + + secret = Vault.logical.write( + "transit/encrypt/#{key}", + plaintext: Base64.strict_encode64(Oj.dump(plaintext)) + ) + + secret.data[:ciphertext] + end + + def batch_encrypt(plaintexts) + return [] if plaintexts.blank? + + secrets = Vault.logical.write( + "transit/encrypt/#{key}", + batch_input: plaintexts.map { |plaintext| { plaintext: Base64.strict_encode64(Oj.dump(plaintext)) } } + ) + + secrets.data[:batch_results].map { |result| result[:ciphertext] } + end + + def decrypt(ciphertext) + return if ciphertext.blank? + + secret = Vault.logical.write( + "transit/decrypt/#{key}", + ciphertext: ciphertext + ) + + Oj.load(Base64.strict_decode64(secret.data[:plaintext])) + end + + def batch_decrypt(ciphertexts) + return [] if ciphertexts.blank? + + secret = Vault.logical.write( + "transit/decrypt/#{key}", + batch_input: ciphertexts.map { |ciphertext| { ciphertext: ciphertext } } + ) + + secret.data[:batch_results].map { |result| Oj.load(Base64.strict_decode64(result[:plaintext])) } + end + + private + + attr_reader :key + end +end diff --git a/spec/dummy/app/models/eager_person.rb b/spec/dummy/app/models/eager_person.rb new file mode 100644 index 00000000..2cb5b450 --- /dev/null +++ b/spec/dummy/app/models/eager_person.rb @@ -0,0 +1,30 @@ +require "binary_serializer" + +class EagerPerson < ActiveRecord::Base + include Vault::EncryptedModel + + self.table_name = "people" + + vault_persist_before_save! + + vault_attribute :ssn + + vault_attribute :credit_card, + encrypted_column: :cc_encrypted, + path: "credit-secrets", + key: "people_credit_cards" + + vault_attribute :details, + serialize: :json + + vault_attribute :business_card, + serialize: BinarySerializer + + vault_attribute :favorite_color, + encode: ->(raw) { "xxx#{raw}xxx" }, + decode: ->(raw) { raw && raw[3...-3] } + + vault_attribute :non_ascii + + vault_attribute :email, convergent: true +end diff --git a/spec/dummy/app/models/lazy_person.rb b/spec/dummy/app/models/lazy_person.rb index 51c6f3b3..ccdf25c2 100644 --- a/spec/dummy/app/models/lazy_person.rb +++ b/spec/dummy/app/models/lazy_person.rb @@ -9,6 +9,9 @@ class LazyPerson < ActiveRecord::Base vault_attribute :ssn + vault_attribute :date_of_birth_plaintext, type: :date + vault_attribute_proxy :date_of_birth, :date_of_birth_plaintext + vault_attribute :credit_card, encrypted_column: :cc_encrypted, path: "credit-secrets", @@ -25,4 +28,6 @@ class LazyPerson < ActiveRecord::Base decode: ->(raw) { raw && raw[3...-3] } vault_attribute :non_ascii + + vault_attribute :passport_number, convergent: true end diff --git a/spec/dummy/app/models/person.rb b/spec/dummy/app/models/person.rb index c52ccae2..cfdc9567 100644 --- a/spec/dummy/app/models/person.rb +++ b/spec/dummy/app/models/person.rb @@ -3,6 +3,17 @@ class Person < ActiveRecord::Base include Vault::EncryptedModel + vault_attribute :date_of_birth_plaintext, type: :date, encrypted_column: :date_of_birth_encrypted + vault_attribute_proxy :date_of_birth, :date_of_birth_plaintext + + vault_attribute :passport_number, encrypted_column: :passport_number_encrypted + + vault_attribute :county_plaintext, encrypted_column: :county_encrypted + vault_attribute_proxy :county, :county_plaintext + + vault_attribute :state_plaintext, encrypted_column: :state_encrypted + vault_attribute_proxy :state, :state_plaintext, encrypted_attribute_only: true + vault_attribute :ssn vault_attribute :credit_card, @@ -21,5 +32,25 @@ class Person < ActiveRecord::Base decode: ->(raw) { raw && raw[3...-3] } vault_attribute :non_ascii -end + vault_attribute :email, convergent: true + + vault_attribute :driving_licence_number, convergent: true + validates :driving_licence_number, vault_uniqueness: true, allow_nil: true + + vault_attribute :ip_address, convergent: true, serialize: :ipaddr, type: 'IPAddr' + validates :ip_address, vault_uniqueness: true, allow_nil: true + + vault_attribute :integer_data, + type: :integer, + serialize: :integer + + vault_attribute :float_data, + type: :float, + serialize: :float + + vault_attribute :time_data, + type: :time, + encode: -> (raw) { raw.to_s if raw }, + decode: -> (raw) { raw.to_time if raw } +end diff --git a/spec/dummy/app/models/typed_person.rb b/spec/dummy/app/models/typed_person.rb new file mode 100644 index 00000000..6bfcaa9d --- /dev/null +++ b/spec/dummy/app/models/typed_person.rb @@ -0,0 +1,33 @@ +require "binary_serializer" + +class TypedPerson < ActiveRecord::Base + include Vault::EncryptedModel + + self.table_name = "people" + + # types with default serializers + vault_attribute :integer_data, type: :integer + + vault_attribute :float_data, type: :float + + vault_attribute :time_data, type: :time + + vault_attribute :date_data, encrypted_column: :state_encrypted, type: :date + + vault_attribute :date_time_data, encrypted_column: :county_encrypted, type: :datetime + + # types that do not have default serializers + vault_attribute :decimal_data, encrypted_column: :ssn_encrypted, type: :decimal + + vault_attribute :string_data, encrypted_column: :cc_encrypted, type: :string + + vault_attribute :text_data, encrypted_column: :address_encrypted, type: :text + + # overriding the default serializer + vault_attribute :custom_date_time_data, type: :datetime, serialize: :date + + vault_attribute :custom_float_data, + type: :datetime, + encode: ->(float_value) { float_value.round.to_s }, + decode: ->(decrypted_value) { decrypted_value.to_f.round } +end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml index 0b956764..99a96a21 100644 --- a/spec/dummy/config/database.yml +++ b/spec/dummy/config/database.yml @@ -5,8 +5,8 @@ default: &default development: <<: *default - database: db/development.sqlite3 + database: <%= ENV['FC_VAULT_RAILS_DUMMY_DATABASE_PATH'] || 'db/development.sqlite3' %> test: <<: *default - database: db/test.sqlite3 + database: 'db/test.sqlite3' diff --git a/spec/dummy/config/initializers/vault.rb b/spec/dummy/config/initializers/vault.rb index 69ee1e46..ff7867ff 100644 --- a/spec/dummy/config/initializers/vault.rb +++ b/spec/dummy/config/initializers/vault.rb @@ -4,7 +4,8 @@ Vault::Rails.configure do |vault| vault.application = "dummy" - vault.address = RSpec::VaultServer.address - vault.token = RSpec::VaultServer.token + + vault.address = ENV['FC_VAULT_RAILS_DUMMY_VAULT_SERVER'] || RSpec::VaultServer.address + vault.token = ENV['FC_VAULT_RAILS_DUMMY_VAULT_TOKEN'] || RSpec::VaultServer.token vault.enabled = true end diff --git a/spec/dummy/db/migrate/20150428220101_create_people.rb b/spec/dummy/db/migrate/20150428220101_create_people.rb index 6979d887..1cef1818 100644 --- a/spec/dummy/db/migrate/20150428220101_create_people.rb +++ b/spec/dummy/db/migrate/20150428220101_create_people.rb @@ -1,4 +1,4 @@ -class CreatePeople < ActiveRecord::Migration +class CreatePeople < ActiveRecord::Migration[5.0] def change create_table :people do |t| t.string :name diff --git a/spec/dummy/db/migrate/20180816090008_add_email_encrypted_to_people.rb b/spec/dummy/db/migrate/20180816090008_add_email_encrypted_to_people.rb new file mode 100644 index 00000000..0a829f60 --- /dev/null +++ b/spec/dummy/db/migrate/20180816090008_add_email_encrypted_to_people.rb @@ -0,0 +1,5 @@ +class AddEmailEncryptedToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :email_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181016143500_add_integer_data_encrypted_to_people.rb b/spec/dummy/db/migrate/20181016143500_add_integer_data_encrypted_to_people.rb new file mode 100644 index 00000000..b4728c77 --- /dev/null +++ b/spec/dummy/db/migrate/20181016143500_add_integer_data_encrypted_to_people.rb @@ -0,0 +1,5 @@ +class AddIntegerDataEncryptedToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :integer_data_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181016143700_add_float_data_encrypted_to_people.rb b/spec/dummy/db/migrate/20181016143700_add_float_data_encrypted_to_people.rb new file mode 100644 index 00000000..bfbe79db --- /dev/null +++ b/spec/dummy/db/migrate/20181016143700_add_float_data_encrypted_to_people.rb @@ -0,0 +1,5 @@ +class AddFloatDataEncryptedToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :float_data_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181016143900_add_time_data_encrypted_to_people.rb b/spec/dummy/db/migrate/20181016143900_add_time_data_encrypted_to_people.rb new file mode 100644 index 00000000..013aeb57 --- /dev/null +++ b/spec/dummy/db/migrate/20181016143900_add_time_data_encrypted_to_people.rb @@ -0,0 +1,5 @@ +class AddTimeDataEncryptedToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :time_data_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181017154000_add_county_and_state_to_people.rb b/spec/dummy/db/migrate/20181017154000_add_county_and_state_to_people.rb new file mode 100644 index 00000000..0eb83ce1 --- /dev/null +++ b/spec/dummy/db/migrate/20181017154000_add_county_and_state_to_people.rb @@ -0,0 +1,10 @@ +class AddCountyAndStateToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :county, :string + add_column :people, :county_encrypted, :string + + add_column :people, :state, :string + add_column :people, :state_encrypted, :string + end +end + diff --git a/spec/dummy/db/migrate/20181030234312_add_date_of_birth_to_people.rb b/spec/dummy/db/migrate/20181030234312_add_date_of_birth_to_people.rb new file mode 100644 index 00000000..cd3ffa48 --- /dev/null +++ b/spec/dummy/db/migrate/20181030234312_add_date_of_birth_to_people.rb @@ -0,0 +1,6 @@ +class AddDateOfBirthToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :date_of_birth, :string + add_column :people, :date_of_birth_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb b/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb new file mode 100644 index 00000000..a3993bd2 --- /dev/null +++ b/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb @@ -0,0 +1,5 @@ +class AddPassportNumberToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :passport_number_encrypted, :string + end +end diff --git a/spec/dummy/db/migrate/20181212095513_add_driving_licence_number_and_ip_address_to_people.rb b/spec/dummy/db/migrate/20181212095513_add_driving_licence_number_and_ip_address_to_people.rb new file mode 100644 index 00000000..bb5a7456 --- /dev/null +++ b/spec/dummy/db/migrate/20181212095513_add_driving_licence_number_and_ip_address_to_people.rb @@ -0,0 +1,6 @@ +class AddDrivingLicenceNumberAndIpAddressToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :driving_licence_number_encrypted, :string + add_column :people, :ip_address_encrypted, :string + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index a6fd409a..6c6f8b06 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -1,4 +1,3 @@ -# encoding: UTF-8 # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. @@ -11,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150428220101) do +ActiveRecord::Schema.define(version: 20181212095513) do create_table "people", force: :cascade do |t| t.string "name" @@ -21,8 +20,21 @@ t.string "business_card_encrypted" t.string "favorite_color_encrypted" t.string "non_ascii_encrypted" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "email_encrypted" + t.string "integer_data_encrypted" + t.string "float_data_encrypted" + t.string "time_data_encrypted" + t.string "county" + t.string "county_encrypted" + t.string "state" + t.string "state_encrypted" + t.string "date_of_birth" + t.string "date_of_birth_encrypted" + t.string "passport_number_encrypted" + t.string "driving_licence_number_encrypted" + t.string "ip_address_encrypted" end end diff --git a/spec/integration/rails_db_create_spec.rb b/spec/integration/rails_db_create_spec.rb new file mode 100644 index 00000000..1e8eb7fe --- /dev/null +++ b/spec/integration/rails_db_create_spec.rb @@ -0,0 +1,24 @@ +# encoding: utf-8 + +require "spec_helper" + +RSpec.describe './bin/rake db:create' do + it "works == the code doesn't need a database to load" do + db_file = File.join(dummy_root, 'db/rails_db_create_spec.sqlite3') + + File.delete(db_file) if File.exist?(db_file) + + command = [ + 'RAILS_ENV=development', + 'FC_VAULT_RAILS_DUMMY_DATABASE_PATH="db/rails_db_create_spec.sqlite3"', + "FC_VAULT_RAILS_DUMMY_VAULT_SERVER='#{RSpec::VaultServer.address}'", + "FC_VAULT_RAILS_DUMMY_VAULT_TOKEN='#{RSpec::VaultServer.token}'", + "#{dummy_root}/bin/rails runner 'puts TypedPerson.class'" + ] + + `#{command.join(' ')}` + + # If the file exists it means that rails tried to connect to the database + expect(File.exist?(db_file)).to eq(false) + end +end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index bb5c8049..e1d63c8e 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -3,8 +3,8 @@ require "spec_helper" describe Vault::Rails do - before(:all) do - Vault::Rails.sys.mount("transit", :transit) + before(:each) do + Person.delete_all end context "with default options" do @@ -40,14 +40,26 @@ expect(person.ssn_was).to eq("123-45-6789") end - it "allows attributes to be unset" do + it 'does not pollute changes / dirty attributes / when loading a record from the db' do + Person.create!(ssn: "123-45-6789") + + expect(Person.last.changes).to be_blank + end + + it "allows attributes to be updated with nil values" do person = Person.create!(ssn: "123-45-6789") - person.update_attributes!(ssn: nil) + person.update!(ssn: nil) person.reload expect(person.ssn).to be(nil) end + it "allows attributes to be unset" do + person = Person.create!(ssn: "123-45-6789") + person.ssn = nil + expect(person.ssn).to be(nil) + end + it "allows saving without validations" do person = Person.new(ssn: "123-456-7890") person.save(validate: false) @@ -57,7 +69,7 @@ it "allows attributes to be unset after reload" do person = Person.create!(ssn: "123-45-6789") person.reload - person.update_attributes!(ssn: nil) + person.update!(ssn: nil) person.reload expect(person.ssn).to be(nil) @@ -65,19 +77,37 @@ it "allows attributes to be blank" do person = Person.create!(ssn: "123-45-6789") - person.update_attributes!(ssn: "") + person.update!(ssn: "") person.reload expect(person.ssn).to eq("") end - it "reloads instance variables on reload" do + it "loads attributes on initialize" do + person = Person.create!(ssn: '123-45-6789', non_ascii: 'some text') + + expect_any_instance_of(Person).to receive(:__vault_load_attributes!).once.and_call_original + Person.__vault_attributes.keys.each do |attr| + expect_any_instance_of(Person).to receive(:__vault_load_attribute!).with(attr, any_args).once + end + + Person.find(person.id) + end + + it "reloads attributes on reload" do person = Person.create!(ssn: "123-45-6789") - expect(person.instance_variable_get(:@ssn)).to eq("123-45-6789") + expect(person.ssn).to eq("123-45-6789") person.ssn = "111-11-1111" + + expect(person).to receive(:__vault_load_attributes!).once.and_call_original + Person.__vault_attributes.keys.each do |attr| + expect(person).to receive(:__vault_load_attribute!).with(attr, any_args).once.and_call_original + end + person.reload - expect(person.instance_variable_get(:@ssn)).to eq("123-45-6789") + + expect(person.ssn).to eq("123-45-6789") end it "does not try to encrypt unchanged attributes" do @@ -109,13 +139,32 @@ end it "does not decrypt on initialization" do - person = LazyPerson.create!(ssn: "123-45-6789") - person.reload + lazy_person = LazyPerson.create!(ssn: "123-45-6789") + + expect_any_instance_of(LazyPerson).not_to receive(:__vault_load_attribute!) + + LazyPerson.find(lazy_person.id) + end + + it 'only decrypts attributes that are used' do + person = LazyPerson.create!(ssn: "123-45-6789", non_ascii: 'some text') - p2 = LazyPerson.find(person.id) + found_person = LazyPerson.find(person.id) + expect(found_person).to receive(:__vault_load_attribute!).with(:ssn, any_args).once - expect(p2.instance_variable_get("@ssn")).to eq(nil) - expect(p2.ssn).to eq("123-45-6789") + found_person.ssn + end + + it 'does not decrypt attributes on reload' do + person = LazyPerson.create!(ssn: "123-45-6789", non_ascii: 'some text') + expect(person.ssn).not_to be_nil + + if Vault::Rails.latest? + expect(person).not_to receive(:__vault_load_attributes!) + end + + person.reload + expect(person.read_attribute(:ssn)).to be nil end it "tracks dirty attributes" do @@ -132,9 +181,18 @@ expect(person.ssn_was).to eq("123-45-6789") end + it 'does not pollute changes / dirty attributes / when loading a record from the db' do + Person.create!(ssn: "123-45-6789") + + person = Person.last + expect(person.changes).to be_blank + expect(person.ssn).to eq('123-45-6789') + expect(person.changes).to be_blank + end + it "allows attributes to be unset" do person = LazyPerson.create!(ssn: "123-45-6789") - person.update_attributes!(ssn: nil) + person.update!(ssn: nil) person.reload expect(person.ssn).to be(nil) @@ -149,25 +207,37 @@ it "allows attributes to be unset after reload" do person = LazyPerson.create!(ssn: "123-45-6789") person.reload - person.update_attributes!(ssn: nil) + person.update!(ssn: nil) person.reload expect(person.ssn).to be(nil) end + it "allows attributes to be unset" do + person = LazyPerson.create!(ssn: "123-45-6789") + person.ssn = nil + expect(person.ssn).to be(nil) + end + it "allows attributes to be blank" do person = LazyPerson.create!(ssn: "123-45-6789") - person.update_attributes!(ssn: "") + person.update!(ssn: "") person.reload expect(person.ssn).to eq("") end - it "reloads instance variables on reload" do + it "resets attributes on reload" do person = LazyPerson.create!(ssn: "123-45-6789") - expect(person.instance_variable_get(:@ssn)).to eq("123-45-6789") + expect(person.ssn).to eq("123-45-6789") person.ssn = "111-11-1111" + + if Vault::Rails.latest? + expect(person).to receive(:__vault_initialize_attributes!).once.and_call_original + end + expect(person).to receive(:__vault_load_attribute!).once.with(:ssn, any_args).and_call_original + person.reload expect(person.ssn).to eq("123-45-6789") @@ -184,7 +254,6 @@ context "with custom options" do before(:all) do - Vault::Rails.sys.mount("credit-secrets", :transit) Vault::Rails.logical.write("credit-secrets/keys/people_credit_cards") end @@ -218,7 +287,7 @@ it "allows attributes to be unset" do person = Person.create!(credit_card: "1234567890111213") - person.update_attributes!(credit_card: nil) + person.update!(credit_card: nil) person.reload expect(person.credit_card).to be(nil) @@ -226,7 +295,7 @@ it "allows attributes to be blank" do person = Person.create!(credit_card: "1234567890111213") - person.update_attributes!(credit_card: "") + person.update!(credit_card: "") person.reload expect(person.credit_card).to eq("") @@ -235,7 +304,6 @@ context "with non-ASCII characters" do before(:all) do - Vault::Rails.sys.mount("non-ascii", :transit) Vault::Rails.logical.write("non-ascii/keys/people_non_ascii") end @@ -269,7 +337,7 @@ it "allows attributes to be unset" do person = Person.create!(non_ascii: "dás ümlaut") - person.update_attributes!(non_ascii: nil) + person.update!(non_ascii: nil) person.reload expect(person.non_ascii).to be(nil) @@ -277,7 +345,7 @@ it "allows attributes to be blank" do person = Person.create!(non_ascii: "dás ümlaut") - person.update_attributes!(non_ascii: "") + person.update!(non_ascii: "") person.reload expect(person.non_ascii).to eq("") @@ -289,13 +357,18 @@ Vault::Rails.logical.write("transit/keys/dummy_people_details") end - it "has a default value for unpersisted records" do + it "allows nil for unpersisted records" do person = Person.new - expect(person.details).to eq({}) + expect(person.details).to be_nil end - it "has a default value for persisted records" do + it "allows nil for persisted records" do person = Person.create! + expect(person.details).to be_nil + end + + it 'saves an empty hash' do + person = Person.create!(details: {}) expect(person.details).to eq({}) end @@ -356,6 +429,49 @@ end end + context 'when convergent encryption is used' do + before :each do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'generates the same ciphertext for the same plaintext' do + email = 'user@example.com' + + first_person = Person.create!(email: email) + second_person = Person.create!(email: email) + + first_person.reload + second_person.reload + + expect(first_person.email_encrypted).not_to be_blank + expect(second_person.email_encrypted).not_to be_blank + + expect(first_person.email_encrypted).to eq second_person.email_encrypted + end + + it 'generates different ciphertexts for different plaintexts' do + first_person = Person.create!(email: 'john@example.com') + second_person = Person.create!(email: 'todd@example.com') + + first_person.reload + second_person.reload + + expect(first_person.email_encrypted).not_to eq(second_person.email_encrypted) + end + end + + context 'when called with type other than string' do + it 'casts the value to the correct type' do + person = Person.new + date_string = '2000-10-10' + date = Date.parse(date_string) + + person.date_of_birth_plaintext = date_string + + expect(person.date_of_birth_plaintext).to eq date + end + end + context 'with errors' do it 'raises the appropriate exception' do expect { @@ -363,4 +479,333 @@ }.to raise_error(Vault::HTTPClientError) end end + + context "in-memory encryption" do + before(:each) do + # Force in-memory encryption + allow(Vault::Rails).to receive(:enabled?).and_return(false) + end + + context 'when convergent encryption is not used' do + it 'generates different ciphertexts for the same plaintext' do + ssn = '123-45-6789' + + first_person = Person.create!(ssn: ssn) + second_person = Person.create!(ssn: ssn) + + first_person.reload + second_person.reload + + expect(first_person.ssn).to eq ssn + expect(second_person.ssn).to eq ssn + + expect(first_person.ssn_encrypted).not_to eq(second_person.ssn_encrypted) + end + end + + context 'when convergent encryption is used' do + before :each do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'generates the same ciphertext when given the same plaintext' do + email = 'knifemaker@example.com' + + first_person = Person.create!(email: email) + second_person = Person.create!(email: email) + + first_person.reload + second_person.reload + + expect(first_person.email_encrypted).not_to be_blank + expect(second_person.email_encrypted).not_to be_blank + + expect(first_person.email_encrypted).to eq(second_person.email_encrypted) + end + + it "generates different ciphertext for different plaintext" do + first_person = Person.create!(email: "medford@example.com") + second_person = Person.create!(email: "begg@example.com") + + first_person.reload + second_person.reload + + expect(first_person.email_encrypted).not_to eq(second_person.email_encrypted) + end + end + + context '.vault_load_all' do + it 'works with records with nil and blank values' do + first_person = LazyPerson.create!(passport_number: nil) + second_person = LazyPerson.create!(passport_number: '') + + first_person.reload + second_person.reload + + LazyPerson.vault_load_all(:passport_number, [first_person, second_person]) + expect(first_person.passport_number).to eq(nil) + expect(second_person.passport_number).to eq('') + end + end + end + + context 'uniqueness validation' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + context 'new record with duplicated driving licence number' do + it 'is invalid' do + Person.create!(driving_licence_number: '12345678') + same_driving_licence_number_person = Person.new(driving_licence_number: '12345678') + + expect(same_driving_licence_number_person).not_to be_valid + expect(same_driving_licence_number_person.errors[:driving_licence_number]).to include('has already been taken') + expect(same_driving_licence_number_person.errors[:driving_licence_number_encrypted]).not_to include('has already been taken') + end + end + + context 'new record with new different licence number' do + it 'is valid' do + Person.create!(driving_licence_number: '12345678') + different_driving_licence_number_person = Person.new(driving_licence_number: '12345679') + + expect(different_driving_licence_number_person).to be_valid + end + end + + context 'old record with duplicated driving licence number' do + it 'is invalid' do + Person.create!(driving_licence_number: '12345678') + another_person = Person.create!(driving_licence_number: '12345679') + another_person.driving_licence_number = '12345678' + + expect(another_person).not_to be_valid + end + end + + context 'attribute with defined serializer' do + context 'new record with duplicated IP address' do + it 'is invalid' do + Person.create!(ip_address: IPAddr.new('127.0.0.1')) + same_ip_address_person = Person.new(ip_address: IPAddr.new('127.0.0.1')) + + expect(same_ip_address_person).not_to be_valid + end + end + + context 'new record with different IP address' do + it 'is valid' do + Person.create!(ip_address: IPAddr.new('127.0.0.1')) + different_ip_address_person = Person.new(ip_address: IPAddr.new('192.168.0.1')) + + expect(different_ip_address_person).to be_valid + end + end + + context 'old record with duplicated IP address' do + it 'is invalid' do + Person.create!(ip_address: IPAddr.new('127.0.0.1')) + another_person = Person.create!(ip_address: IPAddr.new('192.168.0.1')) + another_person.ip_address = IPAddr.new('127.0.0.1') + + expect(another_person).not_to be_valid + end + end + end + end + + context 'batch encryption and decryption' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + describe '.vault_load_all' do + it 'calls Vault just once' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345679') + + people = [first_person.reload, second_person.reload] + expect(Vault.logical).to receive(:write).once.and_call_original + LazyPerson.vault_load_all(:passport_number, people) + + first_person.passport_number + second_person.passport_number + end + + it 'loads the attribute of all records' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345679') + + first_person.reload + second_person.reload + + LazyPerson.vault_load_all(:passport_number, [first_person, second_person]) + expect(first_person.passport_number).to eq('12345678') + expect(second_person.passport_number).to eq('12345679') + end + end + + describe '.vault_persist_all' do + it 'calls Vault just once' do + first_person = LazyPerson.new + second_person = LazyPerson.new + + expect(Vault.logical).to receive(:write).once.and_call_original + LazyPerson.vault_persist_all(:passport_number, [first_person, second_person], %w(12345678 12345679)) + end + + it 'saves the attribute of all records' do + first_person = LazyPerson.new + second_person = LazyPerson.new + + LazyPerson.vault_persist_all(:passport_number, [first_person, second_person], %w(12345678 12345679)) + + expect(first_person.reload.passport_number).to eq('12345678') + expect(second_person.reload.passport_number).to eq('12345679') + end + + context 'skipped validations' do + it 'saves even invalid records' do + first_person = LazyPerson.new + allow(first_person).to receive(:valid?).and_return(false) + + LazyPerson.vault_persist_all(:passport_number, [first_person], %w(12345678), validate: false) + + expect(first_person.reload.passport_number).to eq('12345678') + end + end + end + end + + describe '.encrypted_find_by' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'finds the expected record' do + first_person = LazyPerson.create!(passport_number: '12345678') + LazyPerson.create!(passport_number: '12345678') + LazyPerson.create!(passport_number: '87654321') + + expect(LazyPerson.encrypted_find_by(passport_number: '12345678')).to eq(first_person) + end + + context 'searching by attributes with defined serializer' do + it 'finds the expected record' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1')) + Person.create!(ip_address: IPAddr.new('192.168.0.1')) + + expect(Person.encrypted_find_by(ip_address: IPAddr.new('127.0.0.1'))).to eq(first_person) + end + end + + context 'searching by multiple attributes' do + it 'finds the expected record' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678') + + expect(Person.encrypted_find_by(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678')).to eq(first_person) + end + end + + context 'non-convergently encrypted attributes' do + it 'raises an exception' do + expect { LazyPerson.encrypted_find_by(ssn: '12345678') }.to raise_error('You cannot search with non-convergent fields') + end + end + end + + describe '.encrypted_find_by!' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'finds the expected record' do + first_person = LazyPerson.create!(passport_number: '12345678') + LazyPerson.create!(passport_number: '12345678') + LazyPerson.create!(passport_number: '87654321') + + expect(LazyPerson.encrypted_find_by!(passport_number: '12345678')).to eq(first_person) + end + + context 'searching by attributes with defined serializer' do + it 'finds the expected record' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1')) + Person.create!(ip_address: IPAddr.new('192.168.0.1')) + + expect(Person.encrypted_find_by!(ip_address: IPAddr.new('127.0.0.1'))).to eq(first_person) + end + end + + context 'searching by multiple attributes' do + it 'finds the expected record' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678') + + expect(Person.encrypted_find_by!(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678')).to eq(first_person) + end + end + + context 'searching missing record' do + it 'raises an exception' do + expect { LazyPerson.encrypted_find_by!(passport_number: '000') }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + + context 'non-convergently encrypted attributes' do + it 'raises an exception' do + expect { LazyPerson.encrypted_find_by!(ssn: '12345678') }.to raise_error('You cannot search with non-convergent fields') + end + end + end + + describe '.encrypted_where' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'finds the expected records' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345678') + LazyPerson.create!(passport_number: '87654321') + + expect(LazyPerson.encrypted_where(passport_number: '12345678').pluck(:id)).to match_array([first_person, second_person].map(&:id)) + end + + context 'searching by attributes with defined serializer' do + it 'finds the expected records' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1')) + Person.create!(ip_address: IPAddr.new('192.168.0.1')) + + expect(Person.encrypted_where(ip_address: IPAddr.new('127.0.0.1')).pluck(:id)).to match_array([first_person.id]) + end + end + + context 'searching by multiple attributes' do + it 'finds the expected records' do + first_person = Person.create!(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678') + + expect(Person.encrypted_where(ip_address: IPAddr.new('127.0.0.1'), driving_licence_number: '12345678').pluck(:id)).to match_array([first_person.id]) + end + end + + context 'non-convergently encrypted attributes' do + it 'raises an exception' do + expect { LazyPerson.encrypted_where(ssn: '12345678') }.to raise_error('You cannot search with non-convergent fields') + end + end + end + + describe '.encrypted_where_not' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + it 'finds the expected records' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345678') + third_person = LazyPerson.create!(passport_number: '87654321') + + expect(LazyPerson.encrypted_where_not(passport_number: nil).pluck(:id)).to match_array([first_person, second_person, third_person].map(&:id)) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4dcf41fc..891d2196 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,21 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) -require "vault/rails" +require "vault/rails" require "rspec" +module PathHelpers + def project_root + @project_root ||= File.expand_path('../..', __FILE__) + end + + def dummy_root + File.join(project_root, 'spec', 'dummy') + end +end + RSpec.configure do |config| + config.include PathHelpers + # Prohibit using the should syntax config.expect_with :rspec do |spec| spec.syntax = :expect @@ -22,3 +34,8 @@ end require File.expand_path("../dummy/config/environment.rb", __FILE__) + +# Mount the engines we need for testing +Vault::Rails.sys.mount("transit", :transit) +Vault::Rails.sys.mount("non-ascii", :transit) +Vault::Rails.sys.mount("credit-secrets", :transit) diff --git a/spec/unit/attribute_proxy_spec.rb b/spec/unit/attribute_proxy_spec.rb new file mode 100644 index 00000000..1b214d3f --- /dev/null +++ b/spec/unit/attribute_proxy_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +RSpec.describe 'vault_attribute_proxy' do + let(:person) { Person.new } + + context 'when called with type other than string' do + it 'casts the value to the correct type' do + date_string = '2000-10-10' + date = Date.parse(date_string) + + person.date_of_birth = date_string + + expect(person.date_of_birth).to eq date + expect(person.date_of_birth_plaintext).to eq date + end + end + + context 'with encrypted_attribute_only false' do + it 'fills both attributes' do + county = 'Orange' + person.county = county + + expect(person.county).to eq county + expect(person.county_plaintext).to eq county + end + + it 'reads first from the encrypted attribute' do + person.county_plaintext = 'Yellow' + expect(person.county).to eq('Yellow') + end + + it 'reads from the plain text attribute when encrypted attribute is not available' do + person.county = 'Blue' + person.county_plaintext = nil + + expect(person.county).to eq('Blue') + end + end + + context 'with encrypted_attribute_only true' do + it 'fills only the encrypted attribute' do + person.state = 'California' + + expect(person.state_plaintext).to eq 'California' + expect(person.read_attribute(:state)).to be_nil + end + + it 'reads first from the encrypted attribute' do + person.state_plaintext = 'New York' + expect(person.state).to eq 'New York' + end + + it 'returns nil when encrypted attribute is not available' do + person.state = 'Florida' + person.state_plaintext = nil + + expect(person.state).to be_nil + end + end +end diff --git a/spec/unit/encrypted_model_spec.rb b/spec/unit/encrypted_model_spec.rb index 46c37f07..65734fe4 100644 --- a/spec/unit/encrypted_model_spec.rb +++ b/spec/unit/encrypted_model_spec.rb @@ -1,45 +1,295 @@ require "spec_helper" describe Vault::EncryptedModel do - let(:klass) do - Class.new(ActiveRecord::Base) do - include Vault::EncryptedModel - end - end - describe ".vault_attribute" do + let(:person) { Person.new } + it "raises an exception if a serializer and :encode is given" do expect { - klass.vault_attribute(:foo, serializer: :json, encode: ->(r) { r }) + Person.vault_attribute(:foo, serializer: :json, encode: ->(r) { r }) }.to raise_error(Vault::Rails::ValidationFailedError) end it "raises an exception if a serializer and :decode is given" do expect { - klass.vault_attribute(:foo, serializer: :json, decode: ->(r) { r }) + Person.vault_attribute(:foo, serializer: :json, decode: ->(r) { r }) }.to raise_error(Vault::Rails::ValidationFailedError) end it "defines a getter" do - klass.vault_attribute(:foo) - expect(klass.instance_methods).to include(:foo) + expect(person).to respond_to(:ssn) end it "defines a setter" do - klass.vault_attribute(:foo) - expect(klass.instance_methods).to include(:foo=) + expect(person).to respond_to(:ssn=) end it "defines a checker" do - klass.vault_attribute(:foo) - expect(klass.instance_methods).to include(:foo?) + expect(person).to respond_to(:ssn?) end it "defines dirty attribute methods" do - klass.vault_attribute(:foo) - expect(klass.instance_methods).to include(:foo_change) - expect(klass.instance_methods).to include(:foo_changed?) - expect(klass.instance_methods).to include(:foo_was) + expect(person).to respond_to(:ssn_change) + expect(person).to respond_to(:ssn_changed?) + expect(person).to respond_to(:ssn_was) + end + + context 'with custom attribute types' do + it 'defines an integer attribute' do + Vault::Rails.logical.write("transit/keys/dummy_people_integer_data") + + person = Person.new + person.integer_data = '1' + + expect(person.integer_data).to eq 1 + + person.save + person.reload + + expect(person.integer_data).to eq 1 + end + + it 'defines a float attribute' do + Vault::Rails.logical.write("transit/keys/dummy_people_float_data") + + person = Person.new + person.float_data = '1' + + expect(person.float_data).to eq 1.0 + + person.save + person.reload + + expect(person.float_data).to eq 1.0 + end + + it 'defines a time attribute' do + Vault::Rails.logical.write("transit/keys/dummy_people_time_data") + + time = Time.parse('05:10:15 UTC') + + person = Person.new + person.time_data = time + + person.save + person.reload + + person_time = person.time_data.utc + + expect(person_time.hour).to eq time.hour + expect(person_time.min).to eq time.min + expect(person_time.sec).to eq time.sec + end + + if Vault::Rails.latest? + it 'raises an error with unknown attribute type' do + expect do + Person.vault_attribute :unrecognized_attr, type: :unrecognized + end.to raise_error RuntimeError, /Unrecognized attribute type/ + end + + it 'defines a default serialzer if it has one for the type' do + time_data_vault_options = TypedPerson.__vault_attributes[:time_data] + expect(time_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::TimeSerializer + + integer_data_vault_options = TypedPerson.__vault_attributes[:integer_data] + expect(integer_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::IntegerSerializer + + float_data_vault_options = TypedPerson.__vault_attributes[:float_data] + expect(float_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::FloatSerializer + + date_data_vault_options = TypedPerson.__vault_attributes[:date_data] + expect(date_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::DateSerializer + + date_time_data_vault_options = TypedPerson.__vault_attributes[:date_time_data] + expect(date_time_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::DateTimeSerializer + end + + it 'does not add a default serialzer if it does not have one for the type' do + string_data_vault_options = TypedPerson.__vault_attributes[:string_data] + expect(string_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::StringSerializer + + decimal_data_vault_options = TypedPerson.__vault_attributes[:decimal_data] + expect(decimal_data_vault_options[:serializer]).to be_nil + + text_data_vault_options = TypedPerson.__vault_attributes[:text_data] + expect(text_data_vault_options[:serializer]).to be_nil + end + end + + it 'allows overriding the default serialzer via the `serializer` option' do + custom_date_time_data_vault_options = TypedPerson.__vault_attributes[:custom_date_time_data] + expect(custom_date_time_data_vault_options[:serializer]).not_to eq Vault::Rails::Serializers::DateTimeSerializer + expect(custom_date_time_data_vault_options[:serializer]).to eq Vault::Rails::Serializers::DateSerializer + end + + it 'allows overriding the default serializer via the `encode` and `decode` options' do + custom_float_data_vault_options = TypedPerson.__vault_attributes[:custom_float_data] + expect(custom_float_data_vault_options[:serializer]).not_to eq Vault::Rails::Serializers::FloatSerializer + # we can't reasonably assert on the value of serializer, so we'll + # check what it does instead + expect(custom_float_data_vault_options[:serializer].encode(1.5)).to eq '2' + expect(custom_float_data_vault_options[:serializer].decode('1.5')).to eq 2 + end + end + end + + describe '#attributes' do + let(:person) { Person.new } + + it 'returns all attributes' do + expect(person.attributes).to eq( + "business_card" => nil, + "business_card_encrypted" => nil, + "cc_encrypted" => nil, + "county" => nil, + "county_encrypted" => nil, + "county_plaintext" => nil, + "created_at" => nil, + "credit_card" => nil, + "date_of_birth" => nil, + "date_of_birth_encrypted" => nil, + "date_of_birth_plaintext" => nil, + "details" => nil, + "details_encrypted" => nil, + "driving_licence_number" => nil, + "driving_licence_number_encrypted" => nil, + "email" => nil, + "email_encrypted" => nil, + "favorite_color" => nil, + "favorite_color_encrypted" => nil, + "float_data" => nil, + "float_data_encrypted" => nil, + "id" => nil, + "integer_data" => nil, + "integer_data_encrypted" => nil, + "ip_address" => nil, + "ip_address_encrypted" => nil, + "name" => nil, + "non_ascii" => nil, + "non_ascii_encrypted" => nil, + 'passport_number' => nil, + "passport_number_encrypted" => nil, + "ssn" => nil, + "ssn_encrypted" => nil, + "state" => nil, + "state_encrypted" => nil, + "state_plaintext" => nil, + "time_data" => nil, + "time_data_encrypted" => nil, + "updated_at" => nil + ) + end + end + + describe '#unencrypted_attributes' do + let(:person) { Person.new } + + it 'returns all attributes apart from encrypted fields' do + expect(person.unencrypted_attributes).to eq( + 'business_card' => nil, + 'county' => nil, + 'county_plaintext' => nil, + 'created_at' => nil, + 'credit_card' => nil, + 'date_of_birth' => nil, + 'date_of_birth_plaintext' => nil, + 'details' => nil, + 'driving_licence_number' => nil, + 'email' => nil, + 'favorite_color' => nil, + 'float_data' => nil, + 'id' => nil, + 'integer_data' => nil, + 'ip_address' => nil, + 'name' => nil, + 'non_ascii' => nil, + 'passport_number' => nil, + 'ssn' => nil, + 'state' => nil, + 'state_plaintext' => nil, + 'time_data' => nil, + 'updated_at' => nil + ) + end + end + + describe '#vault_persist_before_save!' do + context "when not used" do + # Person hasn't had `vault_persist_before_save!` called on it + let(:model_class) { Person } + + it "the model has an after_save callback" do + save_callbacks = model_class._save_callbacks.select do |cb| + cb.filter == :__vault_persist_attributes! + end + + expect(save_callbacks.length).to eq 1 + persist_callback = save_callbacks.first + + expect(persist_callback).to be_a ActiveSupport::Callbacks::Callback + + expect(persist_callback.kind).to eq :after + end + + it 'calls the correct callback' do + record = model_class.new(ssn: '123-45-6789') + expect(record).to receive(:__vault_persist_attributes!) + + record.save + end + + it 'encrypts the attribute if it has been saved' do + record = model_class.new(ssn: '123-45-6789') + expect(Vault::Rails).to receive(:encrypt).with('transit', 'dummy_people_ssn', anything, anything, anything).and_call_original + + record.save + + expect(record.ssn_encrypted).not_to be_nil + end + end + + context "when used" do + # EagerPerson has had `vault_persist_before_save!` called on it + let(:model_class) { EagerPerson } + + it "the model does not have an after_save callback" do + save_callbacks = model_class._save_callbacks.select do |cb| + cb.filter == :__vault_persist_attributes! + end + + expect(save_callbacks.length).to eq 0 + end + + it "the model has a before_save callback" do + save_callbacks = model_class._save_callbacks.select do |cb| + cb.filter == :__vault_encrypt_attributes! + end + + expect(save_callbacks.length).to eq 1 + persist_callback = save_callbacks.first + + expect(persist_callback).to be_a ActiveSupport::Callbacks::Callback + + expect(persist_callback.kind).to eq :before + end + + it 'calls the correct callback' do + record = model_class.new(ssn: '123-45-6789') + expect(record).not_to receive(:__vault_persist_attributes!) + expect(record).to receive(:__vault_encrypt_attributes!) + + record.save + end + + it 'encrypts the attribute if it has been saved' do + record = model_class.new(ssn: '123-45-6789') + expect(Vault::Rails).to receive(:encrypt).with('transit', 'dummy_people_ssn',anything,anything,anything).and_call_original + + record.save + + expect(record.ssn_encrypted).not_to be_nil + end end end end diff --git a/spec/unit/perform_in_batches_spec.rb b/spec/unit/perform_in_batches_spec.rb new file mode 100644 index 00000000..2f133a55 --- /dev/null +++ b/spec/unit/perform_in_batches_spec.rb @@ -0,0 +1,227 @@ +require 'spec_helper' + +describe Vault::PerformInBatches do + describe '#encrypt' do + context 'non-convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + column: 'test_attribute_encrypted', + convergent: false + } + end + + it 'raises an exception for non-convergent attributes' do + attribute = 'test_attribute' + records = [double(:first_object, save: true), double(:second_object, save: true)] + plaintexts = %w(plaintext1 plaintext2) + + expect do + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end.to raise_error 'Batch Operations work only with convergent attributes' + end + end + + context 'convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + convergent: true + } + end + + it 'encrypts one attribute for a batch of records and saves it' do + attribute = 'test_attribute' + + first_record = double(save: true) + second_record = double(save: true) + records = [first_record, second_record] + + plaintexts = %w(plaintext1 plaintext2) + + + expect(Vault::Rails).to receive(:batch_encrypt) + .with('test_path', 'test_key', %w(plaintext1 plaintext2), Vault.client) + .and_return(%w(ciphertext1 ciphertext2)) + + expect(first_record).to receive('test_attribute_encrypted=').with('ciphertext1') + expect(second_record).to receive('test_attribute_encrypted=').with('ciphertext2') + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end + + context 'with validation turned off' do + it 'encrypts one attribute for a batch of records and saves it without validations' do + attribute = 'test_attribute' + + first_record = double(save: true) + second_record = double(save: true) + records = [first_record, second_record] + + plaintexts = %w(plaintext1 plaintext2) + + + expect(Vault::Rails).to receive(:batch_encrypt) + .with('test_path', 'test_key', %w(plaintext1 plaintext2), Vault.client) + .and_return(%w(ciphertext1 ciphertext2)) + + expect(first_record).to receive('test_attribute_encrypted=').with('ciphertext1') + expect(second_record).to receive('test_attribute_encrypted=').with('ciphertext2') + + expect(first_record).to receive(:save).with(validate: false) + expect(second_record).to receive(:save).with(validate: false) + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts, validate: false) + end + end + + context 'with given serializer' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + serializer: Vault::Rails::Serializers::IntegerSerializer, + convergent: true + } + end + + it 'encrypts one attribute for a batch of records and saves it' do + attribute = 'test_attribute' + + first_record = double(save: true) + second_record = double(save: true) + records = [first_record, second_record] + + plaintexts = [100, 200] + + expect(Vault::Rails).to receive(:batch_encrypt) + .with('test_path', 'test_key', %w(100 200), Vault.client) + .and_return(%w(ciphertext1 ciphertext2)) + + expect(first_record).to receive('test_attribute_encrypted=').with('ciphertext1') + expect(second_record).to receive('test_attribute_encrypted=').with('ciphertext2') + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end + end + end + end + + describe '#decrypt' do + context 'non-convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + column: 'test_attribute_encrypted', + convergent: false + } + end + + it 'raises an exception for non-convergent attributes' do + attribute = 'test_attribute' + records = [double(:first_object, save: true), double(:second_object, save: true)] + plaintexts = %w(plaintext1 plaintext2) + + expect do + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end.to raise_error 'Batch Operations work only with convergent attributes' + end + end + + context 'convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + convergent: true + } + end + + it 'decrypts one attribute for a batch of records and loads it' do + attribute = 'test_attribute' + + first_record = double(test_attribute_encrypted: 'ciphertext1') + second_record = double(test_attribute_encrypted: 'ciphertext2') + records = [first_record, second_record] + + expect(Vault::Rails).to receive(:batch_decrypt) + .with('test_path', 'test_key', %w(ciphertext1 ciphertext2), Vault.client) + .and_return(%w(plaintext1 plaintext2)) + + if Vault::Rails.latest? + first_record_loaded_attributes = [] + allow(first_record).to receive('__vault_loaded_attributes').and_return(first_record_loaded_attributes) + second_record_loaded_attributes = [] + allow(second_record).to receive('__vault_loaded_attributes').and_return(second_record_loaded_attributes) + end + + if Vault::Rails.latest? + expect(first_record).to receive('write_attribute').with('test_attribute', 'plaintext1') + expect(second_record).to receive('write_attribute').with('test_attribute', 'plaintext2') + else + expect(first_record).to receive('instance_variable_set').with('@test_attribute', 'plaintext1') + expect(second_record).to receive('instance_variable_set').with('@test_attribute', 'plaintext2') + end + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + + if Vault::Rails.latest? + expect(first_record_loaded_attributes).to include(attribute) + expect(second_record_loaded_attributes).to include(attribute) + end + end + + context 'with given serializer' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + serializer: Vault::Rails::Serializers::IntegerSerializer, + convergent: true + } + end + + it 'decrypts one attribute for a batch of records and loads it' do + attribute = 'test_attribute' + + first_record = double(test_attribute_encrypted: 'ciphertext1') + second_record = double(test_attribute_encrypted: 'ciphertext2') + records = [first_record, second_record] + + expect(Vault::Rails).to receive(:batch_decrypt) + .with('test_path', 'test_key', %w(ciphertext1 ciphertext2), Vault.client) + .and_return(%w(100 200)) + + if Vault::Rails.latest? + first_record_loaded_attributes = [] + allow(first_record).to receive('__vault_loaded_attributes').and_return(first_record_loaded_attributes) + second_record_loaded_attributes = [] + allow(second_record).to receive('__vault_loaded_attributes').and_return(second_record_loaded_attributes) + end + + if Vault::Rails.latest? + expect(first_record).to receive('write_attribute').with('test_attribute', 100) + expect(second_record).to receive('write_attribute').with('test_attribute', 200) + else + expect(first_record).to receive('instance_variable_set').with('@test_attribute', 100) + expect(second_record).to receive('instance_variable_set').with('@test_attribute', 200) + end + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + + if Vault::Rails.latest? + expect(first_record_loaded_attributes).to include(attribute) + expect(second_record_loaded_attributes).to include(attribute) + end + end + end + end + end +end diff --git a/spec/unit/rails/configurable_spec.rb b/spec/unit/rails/configurable_spec.rb index c7a8a90f..90e7c839 100644 --- a/spec/unit/rails/configurable_spec.rb +++ b/spec/unit/rails/configurable_spec.rb @@ -40,4 +40,50 @@ end end end + + describe '.encoding' do + context 'when not set explicitly' do + context 'but there is a rails encoding setting' do + it 'returns that value' do + allow(::Rails.application.config).to receive(:encoding).and_return('ISO-8859-1') + expect(subject.encoding).to eq(Encoding::ISO_8859_1) + end + + it 'raises an exception if that value is not a valid encoding' do + allow(::Rails.application.config).to receive(:encoding).and_return('LINEAR_B') + expect { subject.encoding }.to raise_exception(ArgumentError, /unknown encoding name - LINEAR_B/) + end + end + + context 'and there is no rails encoding setting' do + it 'returns UTF-8' do + allow(::Rails.application.config).to receive(:encoding).and_return(nil) + expect(subject.encoding).to eq(Encoding::UTF_8) + end + end + + context 'and the gem is not in a rails app' do + it 'returns UTF-8' do + hide_const('Rails') + expect(subject.encoding).to eq(Encoding::UTF_8) + end + end + end + + context 'when configured' do + it 'returns the configured value' do + subject.configure do |vault| + vault.encoding = 'ISO-8859-1' + end + + expect(subject.encoding).to eq(Encoding::ISO_8859_1) + end + end + end + + context '.encoding=' do + it 'raises an exception if the supplied value is not a valid encoding' do + expect { subject.encoding = 'LINEAR_B' }.to raise_exception(ArgumentError, /unknown encoding name - LINEAR_B/) + end + end end diff --git a/spec/unit/rails/serializers/date_serializer_spec.rb b/spec/unit/rails/serializers/date_serializer_spec.rb new file mode 100644 index 00000000..e58369b1 --- /dev/null +++ b/spec/unit/rails/serializers/date_serializer_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::DateSerializer do + it 'encodes values to strings' do + expect(subject.encode(Date.new(1999, 1, 1))).to eq '1999-01-01' + end + + it 'decodes values from strings' do + expect(subject.decode('1999-12-31')).to eq Date.new(1999, 12, 31) + end +end diff --git a/spec/unit/rails/serializers/date_time_serializer_spec.rb b/spec/unit/rails/serializers/date_time_serializer_spec.rb new file mode 100644 index 00000000..44e64088 --- /dev/null +++ b/spec/unit/rails/serializers/date_time_serializer_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::DateTimeSerializer do + it 'encodes values to strings' do + expect(subject.encode(DateTime.new(1999, 1, 1, 10, 11, 12.134, '0'))).to eq '1999-01-01T10:11:12.134+00:00' + end + + it 'decodes values from strings' do + expect(subject.decode('1999-12-31T20:21:22.234+00:00')).to eq DateTime.new(1999, 12, 31, 20, 21, 22.234, '0') + end +end diff --git a/spec/unit/rails/serializers/float_serializer_spec.rb b/spec/unit/rails/serializers/float_serializer_spec.rb new file mode 100644 index 00000000..bb2108e7 --- /dev/null +++ b/spec/unit/rails/serializers/float_serializer_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::FloatSerializer do + it 'encodes values to strings' do + expect(subject.encode(1.0)).to eq '1.0' + expect(subject.encode(42.00001)).to eq '42.00001' + expect(subject.encode(435345.40035)).to eq '435345.40035' + end + + it 'decodes values from strings' do + expect(subject.decode('1.0')).to eq 1.0 + expect(subject.decode('42')).to eq 42.0 + expect(subject.decode('435345.40035')).to eq 435345.40035 + end +end diff --git a/spec/unit/rails/serializers/integer_serializer_spec.rb b/spec/unit/rails/serializers/integer_serializer_spec.rb new file mode 100644 index 00000000..1188e471 --- /dev/null +++ b/spec/unit/rails/serializers/integer_serializer_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::IntegerSerializer do + it 'encodes values to strings' do + expect(subject.encode(1)).to eq '1' + expect(subject.encode(42)).to eq '42' + expect(subject.encode(23425)).to eq '23425' + end + + it 'decodes values from strings' do + expect(subject.decode('1')).to eq 1 + expect(subject.decode('42')).to eq 42 + expect(subject.decode('23425')).to eq 23425 + end +end diff --git a/spec/unit/rails/serializers/ipaddr_serializer_spec.rb b/spec/unit/rails/serializers/ipaddr_serializer_spec.rb new file mode 100644 index 00000000..996ba50d --- /dev/null +++ b/spec/unit/rails/serializers/ipaddr_serializer_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::IPAddrSerializer do + it 'encodes values to strings for IP4 addresses' do + expect(subject.encode(IPAddr.new('192.168.1.255/32'))).to eq '192.168.1.255/32' + end + + it 'decodes values from strings for IP4 addresses' do + expect(subject.decode('192.168.1.1/1')).to eq IPAddr.new('192.168.1.1/1') + end + + it 'encodes values to strings for IP6 addresses' do + expect(subject.encode(IPAddr.new('fd12:3456:789a:1::ffff/128'))).to eq 'fd12:3456:789a:1::ffff/128' + end + + it 'decodes values from strings for IP6 addresses' do + expect(subject.decode('fd12:3456:789a:1::1/1')).to eq IPAddr.new('fd12:3456:789a:1::1/1') + end +end diff --git a/spec/unit/rails/serializers/json_serializer_spec.rb b/spec/unit/rails/serializers/json_serializer_spec.rb new file mode 100644 index 00000000..0cbe3afc --- /dev/null +++ b/spec/unit/rails/serializers/json_serializer_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::JSONSerializer do + context '.encode' do + it 'encodes values to strings' do + expect(subject.encode({"foo" => "bar", "baz" => 1})).to eq '{"foo":"bar","baz":1}' + end + + it 'returns values already encoded as a JSON string' do + expect(subject.encode('{"anonymised":true}')).to eq('{"anonymised":true}') + end + end + + context '.decode' do + it 'decodes values from strings' do + expect(subject.decode('{"foo":"bar","baz":1}')).to eq({"foo" => "bar", "baz" => 1}) + end + end +end diff --git a/spec/unit/rails/serializers/string_serializer_spec.rb b/spec/unit/rails/serializers/string_serializer_spec.rb new file mode 100644 index 00000000..f4440649 --- /dev/null +++ b/spec/unit/rails/serializers/string_serializer_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::StringSerializer do + context 'blank values' do + it 'encodes blank values without changing them' do + expect(subject.encode(nil)).to eq nil + end + end + + it 'encodes values to strings' do + expect(subject.encode({a: 3})).to eq "{:a=>3}" + end + + it 'decodes the value by simply returing it' do + expect(subject.decode('foo')).to eq 'foo' + end +end diff --git a/spec/unit/rails/serializers/time_serializer_spec.rb b/spec/unit/rails/serializers/time_serializer_spec.rb new file mode 100644 index 00000000..47b74d6e --- /dev/null +++ b/spec/unit/rails/serializers/time_serializer_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Vault::Rails::Serializers::TimeSerializer do + it 'encodes values to strings' do + expect(subject.encode(Time.utc(1999, 1, 1, 10, 11, 12, 134000))).to eq '1999-01-01T10:11:12.134Z' + end + + it 'decodes values from strings' do + expect(subject.decode('1999-12-31T20:21:22.234Z')).to eq Time.utc(1999, 12, 31, 20, 21, 22, 234000) + end +end diff --git a/spec/unit/rails_spec.rb b/spec/unit/rails_spec.rb index 557d4327..a991b230 100644 --- a/spec/unit/rails_spec.rb +++ b/spec/unit/rails_spec.rb @@ -1,23 +1,330 @@ -require "spec_helper" +require 'spec_helper' describe Vault::Rails do - describe ".serializer_for" do - it "accepts a string" do - serializer = Vault::Rails.serializer_for("json") - expect(serializer).to be(Vault::Rails::JSONSerializer) + describe '.serializer_for' do + it 'accepts a string' do + serializer = Vault::Rails.serializer_for('json') + expect(serializer).to be(Vault::Rails::Serializers::JSONSerializer) end - it "accepts a symbol" do + it 'accepts a symbol' do serializer = Vault::Rails.serializer_for(:json) - expect(serializer).to be(Vault::Rails::JSONSerializer) + expect(serializer).to be(Vault::Rails::Serializers::JSONSerializer) end - it "raises an exception when there is no serializer for the key" do - expect { + it 'raises an exception when there is no serializer for the key' do + expect do Vault::Rails.serializer_for(:not_a_serializer) - }.to raise_error(Vault::Rails::UnknownSerializerError) { |e| - expect(e.message).to match("Unknown Vault serializer `:not_a_serializer'") + end.to raise_error(Vault::Rails::Serializers::UnknownSerializerError) { |e| + expect(e.message).to match('Unknown Vault serializer `:not_a_serializer`') } end end + + describe '.encrypt' do + context 'when convergent encryption is enabled' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/encrypt/key' + expected_options = { + plaintext: Base64.strict_encode64('plaintext'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + convergent_encryption: true, + derived: true + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.encrypt('path', 'key', 'plaintext', Vault::Rails.client, true) + end + end + + context 'when convergent encryption is disabled' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/encrypt/key' + expected_options = { plaintext: Base64.strict_encode64('plaintext') } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.encrypt('path', 'key', 'plaintext', Vault::Rails.client, false) + end + end + end + + describe '.decrypt' do + context 'when convergent encryption is enabled' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/decrypt/key' + expected_options = { + ciphertext: 'ciphertext', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context) + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.decrypt('path', 'key', 'ciphertext', Vault::Rails.client, true) + end + end + + context 'when convergent encryption is disabled' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/decrypt/key' + expected_options = { ciphertext: 'ciphertext' } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.decrypt('path', 'key', 'ciphertext', Vault::Rails.client, false) + end + end + end + + describe '.batch_encrypt' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/encrypt/key' + expected_options = { + batch_input: [ + { + plaintext: Base64.strict_encode64('plaintext1'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + plaintext: Base64.strict_encode64('plaintext2'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + convergent_encryption: true, + derived: true + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.batch_encrypt('path', 'key', ['plaintext1', 'plaintext2'], Vault::Rails.client) + end + + it 'parses the response from vault client correctly' do + expected_route = 'path/encrypt/key' + expected_options = { + batch_input: [ + { + plaintext: Base64.strict_encode64('plaintext1'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + plaintext: Base64.strict_encode64('plaintext2'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + convergent_encryption: true, + derived: true + } + + allow(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(instance_double('Vault::Secret', data: {:batch_results=>[{:ciphertext=>'ciphertext1'}, {:ciphertext=>'ciphertext2'}]})) + + expect(Vault::Rails.batch_encrypt('path', 'key', ['plaintext1', 'plaintext2'], Vault::Rails.client)).to eq(%w(ciphertext1 ciphertext2)) + end + + context 'with only blank values' do + it 'does not make any calls to Vault and just return the plaintexts' do + expect(Vault::Rails.client.logical).not_to receive(:write) + + plaintexts = ['', '', nil, '', nil, nil] + expect(Vault::Rails.batch_encrypt('path', 'key', plaintexts, Vault::Rails.client)).to eq(plaintexts) + end + end + + context 'with presented blank values' do + it 'sends the correct parameters to vault client' do + expected_route = 'path/encrypt/key' + expected_options = { + batch_input: [ + { + plaintext: Base64.strict_encode64('plaintext1'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + plaintext: Base64.strict_encode64('plaintext2'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + convergent_encryption: true, + derived: true + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.batch_encrypt('path', 'key', ['plaintext1', '', 'plaintext2', '', nil, nil], Vault::Rails.client) + end + + it 'parses the response from vault client correctly and keeps the order of records' do + expected_route = 'path/encrypt/key' + expected_options = { + batch_input: [ + { + plaintext: Base64.strict_encode64('plaintext1'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + plaintext: Base64.strict_encode64('plaintext2'), + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + convergent_encryption: true, + derived: true + } + + allow(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(instance_double('Vault::Secret', data: {:batch_results=>[{:ciphertext=>'ciphertext1'}, {:ciphertext=>'ciphertext2'}]})) + + expect(Vault::Rails.batch_encrypt('path', 'key', ['plaintext1', '', 'plaintext2', '', nil], Vault::Rails.client)).to eq(['ciphertext1', '', 'ciphertext2', '', nil]) + end + end + end + + describe '.batch_decrypt' do + before do + allow(Vault::Rails).to receive(:enabled?).and_return(true) + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16) + end + + it 'sends the correct parameters to vault client' do + expected_route = 'path/decrypt/key' + expected_options = { + batch_input: [ + { + ciphertext: 'ciphertext1', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + ciphertext: 'ciphertext2', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.batch_decrypt('path', 'key', ['ciphertext1', 'ciphertext2'], Vault::Rails.client) + end + + it 'parses the response from vault client correctly' do + expected_route = 'path/decrypt/key' + expected_options = { + batch_input: [ + { + ciphertext: 'ciphertext1', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + ciphertext: 'ciphertext2', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + } + + allow(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(instance_double('Vault::Secret', data: {:batch_results=>[{:plaintext=>'cGxhaW50ZXh0MQ=='}, {:plaintext=>'cGxhaW50ZXh0Mg=='}]})) + + expect(Vault::Rails.batch_decrypt('path', 'key', ['ciphertext1', 'ciphertext2'], Vault::Rails.client)).to eq( %w(plaintext1 plaintext2)) # in that order + end + + context 'with only blank values' do + it 'does not make any calls to Vault and just return the ciphertexts' do + expect(Vault::Rails.client.logical).not_to receive(:write) + + ciphertexts = ['', '', nil, '', nil, nil] + + expect(Vault::Rails.batch_decrypt('path', 'key', ciphertexts, Vault::Rails.client)).to eq(ciphertexts) + end + end + + context 'with presented blank values' do + it 'sends the correct parameters to vault client' do + expected_route = 'path/decrypt/key' + expected_options = { + batch_input: [ + { + ciphertext: 'ciphertext1', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + ciphertext: 'ciphertext2', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + } + + expect(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(spy('Vault::Secret')) + + Vault::Rails.batch_decrypt('path', 'key', ['ciphertext1', '', 'ciphertext2', nil, '', ''], Vault::Rails.client) + end + + it 'parses the response from vault client correctly and keeps the order of records' do + expected_route = 'path/decrypt/key' + expected_options = { + batch_input: [ + { + ciphertext: 'ciphertext1', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + ciphertext: 'ciphertext2', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + { + ciphertext: 'ciphertext3', + context: Base64.strict_encode64(Vault::Rails.convergent_encryption_context), + }, + ], + } + + allow(Vault::Rails.client.logical).to receive(:write) + .with(expected_route, expected_options) + .and_return(instance_double('Vault::Secret', data: {batch_results: [{plaintext: 'cGxhaW50ZXh0MQ=='}, {plaintext:'cGxhaW50ZXh0Mg=='}, {plaintext: 'cGxhaW50ZXh0Mw=='}]})) + + expect(Vault::Rails.batch_decrypt('path', 'key', ['ciphertext1', '', nil, 'ciphertext2', '', 'ciphertext3'], Vault::Rails.client)).to eq( ['plaintext1', '', nil, 'plaintext2', '', 'plaintext3']) # in that order + end + end + end end diff --git a/spec/unit/transit_json_codec_spec.rb b/spec/unit/transit_json_codec_spec.rb new file mode 100644 index 00000000..8ea401db --- /dev/null +++ b/spec/unit/transit_json_codec_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'spec_helper' +require './lib/vault/transit_json_codec.rb' + +RSpec.describe Vault::TransitJsonCodec do + + subject(:codec) { described_class.new(encryption_key) } + let(:encryption_key) { 'blobbykey' } + + before do + Vault.configure { |c| c.address = Vault.client.address } + end + + describe '#encrypt' do + context 'when plaintext is blank' do + let(:plaintext) { nil } + + it 'returns nil' do + expect(codec.encrypt(plaintext)).to be_nil + end + end + + context 'when plaintext is empty' do + let(:plaintext) { '' } + + it 'returns nil' do + expect(codec.encrypt(plaintext)).to be_nil + end + end + + context 'when plaintext is present' do + let(:plaintext) { 'blobby' } + + context 'when encryption fails' do + before do + allow(Vault).to receive(:logical).and_raise(StandardError, 'Oh no!') + end + + it 're-raises error' do + expect { codec.encrypt(plaintext) }.to raise_error(StandardError) + end + end + + it 'encrypts the field' do + expect(codec.encrypt(plaintext)).to start_with('vault:v1:') + end + end + end + + describe '#decrypt' do + + context 'when ciphertext is nil' do + let(:ciphertext) { nil } + + it 'returns nil' do + expect(codec.decrypt(ciphertext)).to be_nil + end + end + + context 'when ciphertext is empty' do + let(:ciphertext) { '' } + + it 'returns nil' do + expect(codec.decrypt(ciphertext)).to be_nil + end + end + + context 'when ciphertext is present' do + let(:plaintext) { 'blobby' } + + context 'when decoding fails' do + before do + allow(Vault).to receive(:logical).and_raise(StandardError, 'Oh no!') + end + + it 're-raises error' do + expect { codec.decrypt(plaintext) }.to raise_error(StandardError) + end + end + + it 'decrypts an encrypted field' do + encrypted_text = codec.encrypt(plaintext) + expect(codec.decrypt(encrypted_text)).to eq(plaintext) + end + end + end + + describe '#batch_encrypt' do + context 'when plaintexts array is empty' do + it 'returns empty array' do + expect(codec.batch_encrypt([])).to eq([]) + expect(codec.batch_encrypt(nil)).to eq([]) + end + end + + context 'when plaintexts are present' do + let(:plaintexts) { ['some text', 'other text'] } + + it 'returns array of encrypted values' do + ciphertexts = codec.batch_encrypt(plaintexts) + expect(ciphertexts).not_to be_blank + expect(ciphertexts.length).to eq(plaintexts.length) + ciphertexts.each { |ciphertext| expect(ciphertext).to start_with('vault:v1:') } + ciphertexts.each_with_index { |ciphertext, i| expect(codec.decrypt(ciphertext)).to eq(plaintexts[i]) } + end + + context 'when encryption fails' do + before do + allow(Vault).to receive(:logical).and_raise(StandardError, 'Oh no!') + end + + it 're-raises error' do + expect { codec.batch_encrypt(plaintexts) }.to raise_error(StandardError) + end + end + end + end + + describe '#batch_decrypt' do + context 'when ciphertexts array is empty' do + it 'returns empty array' do + expect(codec.batch_decrypt([])).to eq([]) + expect(codec.batch_decrypt(nil)).to eq([]) + end + end + + context 'when ciphertexts are present' do + let(:plaintexts) { ['some text', 'other text'] } + let(:ciphertexts) { codec.batch_encrypt(plaintexts) } + + it 'returns array of decrypted values' do + decrypted = codec.batch_decrypt(ciphertexts) + expect(decrypted).not_to be_blank + expect(decrypted.length).to eq(plaintexts.length) + expect(decrypted).to eq(plaintexts) + end + + context 'when decryption fails' do + before do + allow(Vault).to receive(:logical).and_raise(StandardError, 'Oh no!') + end + + it 're-raises error' do + expect { codec.batch_decrypt(plaintexts) }.to raise_error(StandardError) + end + end + end + end + +end