diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index 239c54dd..56b49aa0 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -1,3 +1,4 @@ +require "pry" require "active_support/concern" module Vault @@ -41,31 +42,22 @@ module ClassMethods # a proc to encode the value with # @option options [Proc] :decode # a proc to decode the value with + # @option options [Hash] :transform_secret + # a hash providing details about the transformation to use, + # this includes the name, and the role to use 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}" - context = options[:context] - default = options[:default] - # Sanity check options! _vault_validate_options!(options) - # Get the serializer if one was given. - serializer = options[:serialize] + parsed_opts = if options[:transform_secret] + parse_transform_secret_attributes(attribute, options) + else + parse_transit_attributes(attribute, options) + end + parsed_opts[:encrypted_column] = options[:encrypted_column] || "#{attribute}_encrypted" - # 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 + # Make a note of this attribute so we can use it in the future (maybe). + __vault_attributes[attribute.to_sym] = parsed_opts self.attribute attribute.to_s, ActiveRecord::Type::Value.new, default: nil @@ -82,7 +74,7 @@ def vault_attribute(attribute, options = {}) # 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. + # that if you call attr=, you want it sent back regardless. attribute_will_change!("#{attribute}") instance_variable_set("@#{attribute}", value) @@ -98,16 +90,6 @@ def vault_attribute(attribute, options = {}) instance_variable_get("@#{attribute}").present? end - # Make a note of this attribute so we can use it in the future (maybe). - __vault_attributes[attribute.to_sym] = { - context: context, - default: default, - encrypted_column: encrypted_column, - key: key, - path: path, - serializer: serializer - } - self end @@ -126,6 +108,11 @@ def _vault_validate_options!(options) raise Vault::Rails::ValidationFailedError, "Cannot use a " \ "custom encoder/decoder if a `:serializer' is specified!" end + + if options[:transform_secret] + raise Vault::Rails::ValidationFailedError, "Cannot use the " \ + "transform secrets engine with a specified `:serializer'!" + end end if options[:encode] && !options[:decode] @@ -144,6 +131,12 @@ def _vault_validate_options!(options) "`:context' must take 1 argument!" end end + if transform_opts = options[:transform_secret] + if !transform_opts[:transformation] + raise Vault::Rails::VaildationFailedError, "Transform Secrets " \ + "requires a transformation name!" + end + end end def vault_lazy_decrypt @@ -161,6 +154,54 @@ def vault_single_decrypt def vault_single_decrypt! @vault_single_decrypt = true end + + private + + def parse_transform_secret_attributes(attribute, options) + opts = {} + opts[:transform_secret] = true + + serializer = Class.new + serializer.define_singleton_method(:encode) do |raw| + return if raw.nil? + resp = Vault::Rails.transform_encode(raw, options[:transform_secret]) + resp.dig(:data, :encoded_value) + end + serializer.define_singleton_method(:decode) do |raw| + return if raw.nil? + resp = Vault::Rails.transform_decode(raw, options[:transform_secret]) + resp.dig(:data, :decoded_value) + end + opts[:serializer] = serializer + opts + end + + def parse_transit_attributes(attribute, options) + opts = {} + opts[:path] = options[:path] || "transit" + opts[:key] = options[:key] || "#{Vault::Rails.application}_#{table_name}_#{attribute}" + opts[:context] = options[:context] + opts[:default] = options[:default] + + # 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 + + opts[:serializer] = serializer + opts + end end included do @@ -209,6 +250,7 @@ def __vault_load_attribute!(attribute, options) column = options[:encrypted_column] context = options[:context] default = options[:default] + transform = options[:transform_secret] # Load the ciphertext ciphertext = read_attribute(column) @@ -222,11 +264,18 @@ def __vault_load_attribute!(attribute, options) # Generate context if needed generated_context = __vault_generate_context(context) - # Load the plaintext value - plaintext = Vault::Rails.decrypt( - path, key, ciphertext, - context: generated_context - ) + if transform + # If this is a secret encrypted with FPE, we do not need to decrypt with vault + # This prevents a double encryption via standard vault encryption and FPE. + # FPE is decrypted later as part of the serializer + plaintext = ciphertext + else + # Load the plaintext value + plaintext = Vault::Rails.decrypt( + path, key, ciphertext, + context: generated_context + ) + end # Deserialize the plaintext value, if a serializer exists if serializer @@ -273,6 +322,7 @@ def __vault_persist_attribute!(attribute, options) serializer = options[:serializer] column = options[:encrypted_column] context = options[:context] + transform = options[:transform_secret] # Only persist changed attributes to minimize requests - this helps # minimize the number of requests to Vault. @@ -297,11 +347,18 @@ def __vault_persist_attribute!(attribute, options) # Generate context if needed generated_context = __vault_generate_context(context) - # Generate the ciphertext and store it back as an attribute - ciphertext = Vault::Rails.encrypt( - path, key, plaintext, - context: generated_context - ) + if transform + # If this is a secret encrypted with FPE, we should not encrypt it in vault + # This prevents a double encryption via standard vault encryption and FPE. + # FPE was performed earlier as part of the serialization process. + ciphertext = plaintext + else + # Generate the ciphertext and store it back as an attribute + ciphertext = Vault::Rails.encrypt( + path, key, plaintext, + context: generated_context + ) + end # Write the attribute back, so that we don't have to reload the record # to get the ciphertext diff --git a/lib/vault/rails.rb b/lib/vault/rails.rb index 85b37315..0d56a107 100644 --- a/lib/vault/rails.rb +++ b/lib/vault/rails.rb @@ -141,6 +141,34 @@ def serializer_for(key) end end + def transform_encode(plaintext, opts={}) + return plaintext if plaintext&.empty? + request_opts = {} + request_opts[:value] = plaintext + + if opts[:transformation] + request_opts[:transformation] = opts[:transformation] + end + + role_name = transform_role_name(opts) + client.transform.encode(role_name: role_name, **request_opts) + end + + def transform_decode(ciphertext, opts={}) + return ciphertext if ciphertext&.empty? + request_opts = {} + request_opts[:value] = ciphertext + + if opts[:transformation] + request_opts[:transformation] = opts[:transformation] + end + + role_name = transform_role_name(opts) + puts request_opts + client.transform.decode(role_name: role_name, **request_opts) + end + + protected # Perform in-memory encryption. This is useful for testing and development. @@ -243,6 +271,10 @@ def log_warning(msg) ::Rails.logger.warn { msg } end end + + def transform_role_name(opts) + opts[:role] || self.default_role_name || self.application + end end end end diff --git a/lib/vault/rails/configurable.rb b/lib/vault/rails/configurable.rb index 4778536b..beb320f7 100644 --- a/lib/vault/rails/configurable.rb +++ b/lib/vault/rails/configurable.rb @@ -125,6 +125,20 @@ def retry_max_wait def retry_max_wait=(val) @retry_max_wait = val end + + # Gets the default role name. + # + # @return [String] + def default_role_name + @default_role_name + end + + # Sets the default role to use with various plugins. + # + # @param [String] val + def default_role_name=(val) + @default_role_name = val + end end end end diff --git a/spec/dummy/app/models/person.rb b/spec/dummy/app/models/person.rb index fc6514a7..c2f49f8b 100644 --- a/spec/dummy/app/models/person.rb +++ b/spec/dummy/app/models/person.rb @@ -38,6 +38,22 @@ class Person < ActiveRecord::Base vault_attribute :context_proc, context: ->(record) { record.encryption_context } + vault_attribute :transform_ssn, + transform_secret: { + transformation: "social_sec" + } + + vault_attribute :bad_transform, + transform_secret: { + transformation: "foobar_transformation" + } + + vault_attribute :bad_role_transform, + transform_secret: { + transformation: "social_sec", + role: "foobar_role" + } + def encryption_context "user_#{id}" end diff --git a/spec/dummy/db/migrate/20150428220101_create_people.rb b/spec/dummy/db/migrate/20150428220101_create_people.rb index 344c262f..7bb59406 100644 --- a/spec/dummy/db/migrate/20150428220101_create_people.rb +++ b/spec/dummy/db/migrate/20150428220101_create_people.rb @@ -13,6 +13,7 @@ def change t.string :context_string_encrypted t.string :context_symbol_encrypted t.string :context_proc_encrypted + t.string :transform_ssn_encrypted t.timestamps null: false end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 769df4b2..2ffe0f76 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -2,11 +2,11 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. @@ -25,6 +25,7 @@ t.string "context_string_encrypted" t.string "context_symbol_encrypted" t.string "context_proc_encrypted" + t.string "transform_ssn_encrypted" t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index cbc75111..ad3523cc 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -628,6 +628,46 @@ end end + context 'with transform_secret', ent_vault: ">= 1.4" do + before(:all) do + Vault::Rails.sys.mount("transform", :transform) + Vault::Rails.client.transform.create_transformation( + "social_sec", + template: "builtin/socialsecuritynumber", + tweak_source: "internal", + type: "fpe", + allowed_roles: [Vault::Rails.application] + ) + Vault::Rails.client.transform.create_role(Vault::Rails.application, transformations: ["social_sec"]) + Vault::Rails.client.transform.create_role("foobar_role", transformations: ["social_sec"]) + end + + it "encrypts the attribute using the given transformation" do + person = Person.create!(transform_ssn: "123-45-6789") + expect(person[:transform_ssn_encrypted]).not_to eq("123-45-6789") + expect(person[:transform_ssn_encrypted]).to match(/\d{3}-\d{2}-\d{4}/) + expect(person.transform_ssn).to eq("123-45-6789") + end + + it "raises an error if the format is incorrect" do + expect{ Person.create!(transform_ssn: "1234-5678-90") }.to( + raise_error(Vault::HTTPClientError, /unable to find matching expression/) + ) + end + + it "raises an error if the transformation does not exist" do + expect{ Person.create!(bad_transform: "nope") }.to( + raise_error(Vault::HTTPClientError, /unable to find transformation/) + ) + end + + it "raises an error if the provided role doesn't have the ability to use the transformation" do + expect{ Person.create!(bad_role_transform: "123-45-6789") }.to( + raise_error(Vault::HTTPClientError, /is not an allowed role for the transformation/) + ) + end + end + context 'with errors' do it 'raises the appropriate exception' do expect { diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4dcf41fc..f4f6b2c6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,12 +3,31 @@ require "rspec" +def vault_version_string + @vault_version_string ||= `vault --version` +end + +TEST_VAULT_VERSION = Gem::Version.new(vault_version_string.match(/(\d+\.\d+\.\d+)/)[1]) + RSpec.configure do |config| # Prohibit using the should syntax config.expect_with :rspec do |spec| spec.syntax = :expect end + # Allow tests to isolate a specific test using +focus: true+. If nothing + # is focused, then all tests are executed. + config.filter_run_when_matching :focus + config.filter_run_excluding vault: lambda { |v| + !vault_meets_requirements?(v) + } + config.filter_run_excluding ent_vault: lambda { |v| + !vault_is_enterprise? || !vault_meets_requirements?(v) + } + config.filter_run_excluding non_ent_vault: lambda { |v| + vault_is_enterprise? || !vault_meets_requirements?(v) + } + # Allow tests to isolate a specific test using +focus: true+. If nothing # is focused, then all tests are executed. config.filter_run(focus: true) @@ -21,4 +40,12 @@ config.order = 'random' end +def vault_is_enterprise? + !!vault_version_string.match(/\+(?:ent|prem)/) +end + +def vault_meets_requirements?(v) + Gem::Requirement.new(v).satisfied_by?(TEST_VAULT_VERSION) +end + require File.expand_path("../dummy/config/environment.rb", __FILE__) diff --git a/vault.gemspec b/vault.gemspec index 7fd5d39e..6cc7b112 100644 --- a/vault.gemspec +++ b/vault.gemspec @@ -18,11 +18,12 @@ Gem::Specification.new do |s| s.test_files = Dir["spec/**/*"] s.add_dependency "rails", [">= 4.1"] - s.add_dependency "vault", "~> 0.5" + s.add_dependency "vault", "~> 0.14" s.add_development_dependency "appraisal", "~> 2.1" s.add_development_dependency "bundler" s.add_development_dependency "pry" + s.add_development_dependency "pry-byebug" s.add_development_dependency "rake", "~> 10.0" s.add_development_dependency "rspec", "~> 3.2" s.add_development_dependency "sqlite3"