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 Transform Secrets engine support #102

Merged
merged 5 commits into from
Jul 8, 2020
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
139 changes: 98 additions & 41 deletions lib/vault/encrypted_model.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "pry"
require "active_support/concern"

module Vault
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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
Valarissa marked this conversation as resolved.
Show resolved Hide resolved
# 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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
calvn marked this conversation as resolved.
Show resolved Hide resolved
else
# Generate the ciphertext and store it back as an attribute
ciphertext = Vault::Rails.encrypt(
calvn marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
32 changes: 32 additions & 0 deletions lib/vault/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/vault/rails/configurable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions spec/dummy/app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/db/migrate/20150428220101_create_people.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions spec/integration/rails_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading