Skip to content

Commit

Permalink
Add support for key derivation with a context
Browse files Browse the repository at this point in the history
This adds support for passing a context to Vault Transit encrypt/decrypt
operations, allowing an application to specify a context per record or a
`belongs_to` relation (such as a user, team, or organization). The
context can be a string, or if given a proc or symbol, will generate the
context for each encrypt/decrypt request.

This changes the signature of `Vault::Rails.encrypt` and
`Vault::Rails.decrypt`. Each had an arity of 3-4, and they now have an
arity of 3 and 2 optional keyword arguments. The minor version number
should be bumped on release (since the major version is still 0).
  • Loading branch information
justincampbell committed May 15, 2019
1 parent dbd0139 commit 35aa472
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 30 deletions.
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 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:

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

3 changes: 3 additions & 0 deletions spec/dummy/db/migrate/20150428220101_create_people.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 13 additions & 10 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 35aa472

Please sign in to comment.