diff --git a/README.md b/README.md index b1e89e15..5dcf5bd9 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,51 @@ vault_attribute :credit_card, - **Note** Changing this value for an existing application will make existing values no longer decryptable! +#### Specifying a context (key derivation) + +Vault Transit supports key derivation, which allows the same key to be used for multiple purposes by deriving a new key based on a context value. + +The context can be specified as a string, symbol, or proc. Symbols (an instance method on the model) and procs are called for each encryption or decryption request, and should return a string. + +- **Note** Changing the context or context generator for an attribute will make existing values no longer decryptable! + +##### String + +With a string, all records will use the same context for this attribute: + +```ruby +vault_attribute :credit_card, + context: "user-cc" +``` + +##### Symbol + +When using a symbol, a method will be called on the record to compute the context: + +```ruby +belongs_to :user + +vault_attribute :credit_card, + context: :encryption_context + +def encryption_context + "user_#{user.id}" +end +``` + +##### Proc + +Given a proc, it will be called each time to compute the key name: + +```ruby +belongs_to :user + +vault_attribute :credit_card, + context: ->(record) { "user_#{record.user.id}" } +``` + +The proc must take a single argument for the record. + #### Specifying a different Vault path By default, the path to the transit backend in Vault is `transit/`. This is customizable by setting the `:path` option when declaring the attribute: diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index 64441ed1..f57cc548 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -29,6 +29,9 @@ module ClassMethods # the path to the transit backend (default: +transit+) # @option options [String] :key # the name of the encryption key (default: +#{app}_#{table}_#{column}+) + # @option options [String, Symbol, Proc] :context + # either a string context, or a symbol or proc used to generate a + # context for key generation # @option options [Symbol, Class] :serializer # the name of the serializer to use (or a class) # @option options [Proc] :encode @@ -39,6 +42,7 @@ 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] # Sanity check options! _vault_validate_options!(options) @@ -111,6 +115,7 @@ def vault_attribute(attribute, options = {}) path: path, serializer: serializer, encrypted_column: encrypted_column, + context: context, } self @@ -142,6 +147,13 @@ def _vault_validate_options!(options) raise Vault::Rails::ValidationFailedError, "Cannot specify " \ "`:decode' without specifying `:encode' as well!" end + + if context = options[:context] + if context.is_a?(Proc) && context.arity != 1 + raise Vault::Rails::ValidationFailedError, "Proc passed to " \ + "`:context' must take 1 argument!" + end + end end def vault_lazy_decrypt @@ -193,6 +205,7 @@ def __vault_load_attribute!(attribute, options) path = options[:path] serializer = options[:serializer] column = options[:encrypted_column] + context = options[:context] # Load the ciphertext ciphertext = read_attribute(column) @@ -203,8 +216,14 @@ def __vault_load_attribute!(attribute, options) return end + # Generate context if needed + generated_context = __vault_generate_context(context) + # Load the plaintext value - plaintext = Vault::Rails.decrypt(path, key, ciphertext) + plaintext = Vault::Rails.decrypt( + path, key, ciphertext, + context: generated_context + ) # Deserialize the plaintext value, if a serializer exists if serializer @@ -244,6 +263,7 @@ def __vault_persist_attribute!(attribute, options) path = options[:path] serializer = options[:serializer] column = options[:encrypted_column] + context = options[:context] # Only persist changed attributes to minimize requests - this helps # minimize the number of requests to Vault. @@ -259,8 +279,14 @@ def __vault_persist_attribute!(attribute, options) plaintext = serializer.encode(plaintext) end + # 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) + ciphertext = Vault::Rails.encrypt( + path, key, plaintext, + context: generated_context + ) # Write the attribute back, so that we don't have to reload the record # to get the ciphertext @@ -270,6 +296,20 @@ def __vault_persist_attribute!(attribute, options) { column => ciphertext } end + # Generates an Vault Transit encryption context for use on derived keys. + def __vault_generate_context(context) + case context + when String + context + when Symbol + send(context) + when Proc + context.call(self) + else + nil + end + 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. diff --git a/lib/vault/rails.rb b/lib/vault/rails.rb index 032e71be..1441288e 100644 --- a/lib/vault/rails.rb +++ b/lib/vault/rails.rb @@ -72,7 +72,7 @@ def respond_to_missing?(m, include_private = false) # # @return [String] # the encrypted cipher text - def encrypt(path, key, plaintext, client = self.client) + def encrypt(path, key, plaintext, client: self.client, context: nil) if plaintext.blank? return plaintext end @@ -82,9 +82,9 @@ def encrypt(path, key, plaintext, client = self.client) with_retries do if self.enabled? - result = self.vault_encrypt(path, key, plaintext, client) + result = self.vault_encrypt(path, key, plaintext, client: client, context: context) else - result = self.memory_encrypt(path, key, plaintext, client) + result = self.memory_encrypt(path, key, plaintext, client: client, context: context) end return self.force_encoding(result) @@ -104,7 +104,7 @@ 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, context: nil) if ciphertext.blank? return ciphertext end @@ -114,9 +114,9 @@ def decrypt(path, key, ciphertext, client = self.client) with_retries do if self.enabled? - result = self.vault_decrypt(path, key, ciphertext, client) + result = self.vault_decrypt(path, key, ciphertext, client: client, context: context) else - result = self.memory_decrypt(path, key, ciphertext, client) + result = self.memory_decrypt(path, key, ciphertext, client: client, context: context) end return self.force_encoding(result) @@ -143,48 +143,56 @@ def serializer_for(key) 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: , context: nil) log_warning(DEV_WARNING) if self.in_memory_warnings_enabled? return nil if plaintext.nil? cipher = OpenSSL::Cipher::AES.new(128, :CBC) cipher.encrypt - cipher.key = memory_key_for(path, key) + cipher.key = memory_key_for(path, key) + context return Base64.strict_encode64(cipher.update(plaintext) + cipher.final) 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: , context: nil) log_warning(DEV_WARNING) if self.in_memory_warnings_enabled? return nil if ciphertext.nil? cipher = OpenSSL::Cipher::AES.new(128, :CBC) cipher.decrypt - cipher.key = memory_key_for(path, key) + cipher.key = memory_key_for(path, key) + context return cipher.update(Base64.strict_decode64(ciphertext)) + cipher.final 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: , context: nil) return nil if plaintext.nil? - route = File.join(path, "encrypt", key) - secret = client.logical.write(route, - plaintext: Base64.strict_encode64(plaintext), - ) + route = File.join(path, "encrypt", key) + + data = { plaintext: Base64.strict_encode64(plaintext) } + data[:context] = Base64.strict_encode64(context) if context + + secret = client.logical.write(route, data) + return secret.data[:ciphertext] 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: , context: nil) return nil if ciphertext.nil? - route = File.join(path, "decrypt", key) - secret = client.logical.write(route, ciphertext: ciphertext) + route = File.join(path, "decrypt", key) + + data = { ciphertext: ciphertext } + data[:context] = Base64.strict_encode64(context) if context + + secret = client.logical.write(route, data) + return Base64.strict_decode64(secret.data[:plaintext]) end diff --git a/spec/dummy/app/models/lazy_person.rb b/spec/dummy/app/models/lazy_person.rb index 51c6f3b3..d853dfb6 100644 --- a/spec/dummy/app/models/lazy_person.rb +++ b/spec/dummy/app/models/lazy_person.rb @@ -25,4 +25,14 @@ class LazyPerson < ActiveRecord::Base decode: ->(raw) { raw && raw[3...-3] } vault_attribute :non_ascii + + vault_attribute :context_symbol, + context: :encryption_context + + vault_attribute :context_proc, + context: ->(record) { record.encryption_context } + + def encryption_context + "user_#{id}" + end end diff --git a/spec/dummy/app/models/person.rb b/spec/dummy/app/models/person.rb index c52ccae2..460f22a3 100644 --- a/spec/dummy/app/models/person.rb +++ b/spec/dummy/app/models/person.rb @@ -21,5 +21,18 @@ class Person < ActiveRecord::Base decode: ->(raw) { raw && raw[3...-3] } vault_attribute :non_ascii + + vault_attribute :context_string, + context: "production" + + vault_attribute :context_symbol, + context: :encryption_context + + vault_attribute :context_proc, + context: ->(record) { record.encryption_context } + + def encryption_context + "user_#{id}" + end end diff --git a/spec/dummy/db/migrate/20150428220101_create_people.rb b/spec/dummy/db/migrate/20150428220101_create_people.rb index 7c3d5cac..7b13e820 100644 --- a/spec/dummy/db/migrate/20150428220101_create_people.rb +++ b/spec/dummy/db/migrate/20150428220101_create_people.rb @@ -8,6 +8,9 @@ def change t.string :business_card_encrypted t.string :favorite_color_encrypted t.string :non_ascii_encrypted + t.string :context_string_encrypted + t.string :context_symbol_encrypted + t.string :context_proc_encrypted t.timestamps null: false end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index fb33ca3f..98635e28 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,18 +10,21 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150428220101) do +ActiveRecord::Schema.define(version: 2015_04_28_220101) do create_table "people", force: :cascade do |t| - t.string "name" - t.string "ssn_encrypted" - t.string "cc_encrypted" - t.string "details_encrypted" - 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.string "name" + t.string "ssn_encrypted" + t.string "cc_encrypted" + t.string "details_encrypted" + t.string "business_card_encrypted" + t.string "favorite_color_encrypted" + t.string "non_ascii_encrypted" + t.string "context_string_encrypted" + t.string "context_symbol_encrypted" + t.string "context_proc_encrypted" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index bb5c8049..6719ffcd 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -356,6 +356,89 @@ end end + context "with context" do + it "encodes and decodes with a string context" do + person = Person.create!(context_string: "foobar") + person.reload + + raw = Vault::Rails.decrypt( + "transit", "dummy_people_context_string", + person.context_string_encrypted, context: "production") + + expect(raw).to eq("foobar") + + expect(person.context_string).to eq("foobar") + + # Decrypting without the correct context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_string", + person.context_string_encrypted, context: "wrongcontext") + }.to raise_error(Vault::HTTPClientError, /invalid ciphertext/) + + # Decrypting without a context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_string", + person.context_string_encrypted) + }.to raise_error(Vault::HTTPClientError, /context/) + end + + it "encodes and decodes with a symbol context" do + person = Person.create!(context_symbol: "foobar") + person.reload + + raw = Vault::Rails.decrypt( + "transit", "dummy_people_context_symbol", + person.context_symbol_encrypted, context: person.encryption_context) + + expect(raw).to eq("foobar") + + expect(person.context_symbol).to eq("foobar") + + # Decrypting without the correct context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_symbol", + person.context_symbol_encrypted, context: "wrongcontext") + }.to raise_error(Vault::HTTPClientError, /invalid ciphertext/) + + # Decrypting without a context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_symbol", + person.context_symbol_encrypted) + }.to raise_error(Vault::HTTPClientError, /context/) + end + + it "encodes and decodes with a proc context" do + person = Person.create!(context_proc: "foobar") + person.reload + + raw = Vault::Rails.decrypt( + "transit", "dummy_people_context_proc", + person.context_proc_encrypted, context: person.encryption_context) + + expect(raw).to eq("foobar") + + expect(person.context_proc).to eq("foobar") + + # Decrypting without the correct context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_proc", + person.context_proc_encrypted, context: "wrongcontext") + }.to raise_error(Vault::HTTPClientError, /invalid ciphertext/) + + # Decrypting without a context fails + expect { + Vault::Rails.decrypt( + "transit", "dummy_people_context_proc", + person.context_proc_encrypted) + }.to raise_error(Vault::HTTPClientError, /context/) + end + end + context 'with errors' do it 'raises the appropriate exception' do expect {