Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for key derivation with a context #78

Merged
merged 3 commits into from
May 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 context:

```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:

Expand Down
44 changes: 42 additions & 2 deletions lib/vault/encrypted_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -111,6 +115,7 @@ def vault_attribute(attribute, options = {})
path: path,
serializer: serializer,
encrypted_column: encrypted_column,
context: context,
}

self
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
44 changes: 26 additions & 18 deletions lib/vault/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions spec/dummy/app/models/lazy_person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions spec/dummy/app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

5 changes: 4 additions & 1 deletion spec/dummy/db/migrate/20150428220101_create_people.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class CreatePeople < ActiveRecord::Migration
class CreatePeople < ActiveRecord::Migration[4.2]
def change
create_table :people do |t|
t.string :name
Expand All @@ -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
Expand Down
24 changes: 13 additions & 11 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,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
Loading