mirror of https://github.com/rails/rails
Initial extraction from active_record_encryption gem
This commit is contained in:
parent
c6ee8c5c4f
commit
638a92f734
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActionText
|
||||
class EncryptedRichText < RichText
|
||||
self.table_name = "action_text_rich_texts"
|
||||
|
||||
encrypts :body
|
||||
end
|
||||
end
|
|
@ -24,7 +24,7 @@ module ActionText
|
|||
#
|
||||
# Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments.
|
||||
# Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments.
|
||||
def has_rich_text(name)
|
||||
def has_rich_text(name, encrypted: false)
|
||||
class_eval <<-CODE, __FILE__, __LINE__ + 1
|
||||
def #{name}
|
||||
rich_text_#{name} || build_rich_text_#{name}
|
||||
|
@ -39,8 +39,9 @@ module ActionText
|
|||
end
|
||||
CODE
|
||||
|
||||
rich_text_class_name = encrypted ? "ActionText::EncryptedRichText" : "ActionText::RichText"
|
||||
has_one :"rich_text_#{name}", -> { where(name: name) },
|
||||
class_name: "ActionText::RichText", as: :record, inverse_of: :record, autosave: true, dependent: :destroy
|
||||
class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy
|
||||
|
||||
scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
|
||||
scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class EncryptedMessage < ApplicationRecord
|
||||
self.table_name = "messages"
|
||||
|
||||
has_rich_text :content, encrypted: true
|
||||
end
|
|
@ -45,4 +45,11 @@ class ActiveSupport::TestCase
|
|||
end
|
||||
end
|
||||
|
||||
# Encryption
|
||||
ActiveRecord::Encryption.configure \
|
||||
master_key: "test master key",
|
||||
deterministic_key: "test deterministic key",
|
||||
key_derivation_salt: "testing key derivation salt",
|
||||
support_unencrypted_data: true
|
||||
|
||||
require_relative "../../tools/test_common"
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class ActionText::ModelEncryptionTest < ActiveSupport::TestCase
|
||||
test "encrypt content based on :encrypted option at declaration time" do
|
||||
encrypted_message = EncryptedMessage.create!(subject: "Greetings", content: "Hey there")
|
||||
assert_encrypted_rich_text_attribute encrypted_message, :content, "Hey there"
|
||||
|
||||
clear_message = Message.create!(subject: "Greetings", content: "Hey there")
|
||||
assert_not_encrypted_rich_text_attribute clear_message, :content, "Hey there"
|
||||
end
|
||||
|
||||
test "include rich text attributes when encrypting the model" do
|
||||
content = "<p>the space force is here, we are safe now!</p>"
|
||||
|
||||
message = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedMessage.create!(subject: "Greetings", content: content)
|
||||
end
|
||||
|
||||
message.encrypt
|
||||
|
||||
assert_encrypted_rich_text_attribute(message, :content, content)
|
||||
end
|
||||
|
||||
test "encrypts lets you skip rich texts when encrypting" do
|
||||
content = "<p>the space force is here, we are safe now!</p>"
|
||||
|
||||
message = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedMessage.create!(subject: "Greetings", content: content)
|
||||
end
|
||||
|
||||
message.encrypt(skip_rich_texts: true)
|
||||
|
||||
assert_not_encrypted_rich_text_attribute(message, :content, content)
|
||||
end
|
||||
|
||||
private
|
||||
def assert_encrypted_rich_text_attribute(model, attribute_name, expected_value)
|
||||
assert_not_equal expected_value, model.send(attribute_name).ciphertext_for(:body)
|
||||
assert_equal expected_value, model.reload.send(attribute_name).body.to_html
|
||||
end
|
||||
|
||||
def assert_not_encrypted_rich_text_attribute(model, attribute_name, expected_value)
|
||||
assert_equal expected_value, model.send(attribute_name).ciphertext_for(:body)
|
||||
assert_equal expected_value, model.reload.send(attribute_name).body.to_html
|
||||
end
|
||||
end
|
||||
|
|
@ -43,6 +43,7 @@ module ActiveRecord
|
|||
autoload :CounterCache
|
||||
autoload :DynamicMatchers
|
||||
autoload :DelegatedType
|
||||
autoload :Encryption
|
||||
autoload :Enum
|
||||
autoload :InternalMetadata
|
||||
autoload :Explain
|
||||
|
|
|
@ -327,6 +327,7 @@ module ActiveRecord #:nodoc:
|
|||
include SecureToken
|
||||
include SignedId
|
||||
include Suppressor
|
||||
include Encryption::EncryptableRecord
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:active_record, Base)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
require "active_support/core_ext/module"
|
||||
require "active_support/core_ext/array"
|
||||
|
||||
module ActiveRecord
|
||||
module Encryption
|
||||
extend ActiveSupport::Autoload
|
||||
|
||||
autoload :Cipher
|
||||
autoload :Config
|
||||
autoload :Configurable
|
||||
autoload :Context
|
||||
autoload :Contexts
|
||||
autoload :DerivedSecretKeyProvider
|
||||
autoload :EncryptableRecord
|
||||
autoload :EncryptedAttributeType
|
||||
autoload :EncryptedFixtures
|
||||
autoload :EncryptingOnlyEncryptor
|
||||
autoload :Encryptor
|
||||
autoload :EnvelopeEncryptionKeyProvider
|
||||
autoload :Errors
|
||||
autoload :ExtendedDeterministicQueries
|
||||
autoload :Key
|
||||
autoload :KeyGenerator
|
||||
autoload :KeyProvider
|
||||
autoload :MassEncryption
|
||||
autoload :Message
|
||||
autoload :MessageSerializer
|
||||
autoload :NullEncryptor
|
||||
autoload :Properties
|
||||
autoload :ReadOnlyNullEncryptor
|
||||
|
||||
class Cipher
|
||||
extend ActiveSupport::Autoload
|
||||
autoload :Aes256Gcm
|
||||
end
|
||||
|
||||
include Configurable, Contexts
|
||||
|
||||
ActiveRecord::Type.register(:encrypted, EncryptedAttributeType)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,51 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# The algorithm used for encrypting and decrypting +Message+ objects.
|
||||
#
|
||||
# It uses AES-256-GCM. It will generate a random IV for non deterministic encryption (default)
|
||||
# or derive an initialization vector from the encrypted content for deterministic encryption.
|
||||
#
|
||||
# See +Cipher::Aes256Gcm+
|
||||
class Cipher
|
||||
DEFAULT_ENCODING = Encoding::UTF_8
|
||||
|
||||
# Encrypts the provided text and return an encrypted +Message+
|
||||
def encrypt(clean_text, key:, deterministic: false)
|
||||
cipher_for(key, deterministic: deterministic).encrypt(clean_text).tap do |message|
|
||||
message.headers.encoding = clean_text.encoding.name unless clean_text.encoding == DEFAULT_ENCODING
|
||||
end
|
||||
end
|
||||
|
||||
# Decrypt the provided +Message+
|
||||
#
|
||||
# When +key+ is an Array, it will try all the keys raising a
|
||||
# +ActiveRecord::Encryption::Errors::Decryption+ if none works
|
||||
def decrypt(encrypted_message, key:)
|
||||
try_to_decrypt_with_each(encrypted_message, keys: Array(key)).tap do |decrypted_text|
|
||||
decrypted_text.force_encoding(encrypted_message.headers.encoding || DEFAULT_ENCODING)
|
||||
end
|
||||
end
|
||||
|
||||
def key_length
|
||||
Aes256Gcm.key_length
|
||||
end
|
||||
|
||||
def iv_length
|
||||
Aes256Gcm.iv_length
|
||||
end
|
||||
|
||||
private
|
||||
def try_to_decrypt_with_each(encrypted_text, keys:)
|
||||
keys.each.with_index do |key, index|
|
||||
return cipher_for(key).decrypt(encrypted_text)
|
||||
rescue ActiveRecord::Encryption::Errors::Decryption
|
||||
raise if index == keys.length - 1
|
||||
end
|
||||
end
|
||||
|
||||
def cipher_for(secret, deterministic: false)
|
||||
Aes256Gcm.new(secret, deterministic: deterministic)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,97 @@
|
|||
require "openssl"
|
||||
require "base64"
|
||||
|
||||
module ActiveRecord
|
||||
module Encryption
|
||||
class Cipher
|
||||
# A 256-GCM cipher
|
||||
#
|
||||
# This code is extracted from +ActiveSupport::MessageEncryptor+. Not using it directly because we want to control
|
||||
# the message format and only serialize things once at the +ActiveRecord::Encryption::Message+ level. Also, this
|
||||
# cipher is prepared to deal with deterministic/non deterministic encryption modes.
|
||||
#
|
||||
# By default it will use random initialization vectors. For deterministic encryption, it will use a SHA-256 hash of
|
||||
# the text to encrypt and the secret.
|
||||
#
|
||||
# See https://3.basecamp.com/2914079/buckets/14968485/todos/2426424308
|
||||
# See +Encryptor+
|
||||
class Aes256Gcm
|
||||
CIPHER_TYPE = "aes-256-gcm"
|
||||
|
||||
class << self
|
||||
def key_length
|
||||
OpenSSL::Cipher.new(CIPHER_TYPE).key_len
|
||||
end
|
||||
|
||||
def iv_length
|
||||
OpenSSL::Cipher.new(CIPHER_TYPE).iv_len
|
||||
end
|
||||
end
|
||||
|
||||
# When iv not provided, it will generate a random iv on each encryption operation (default and
|
||||
# recommended operation)
|
||||
def initialize(secret, deterministic: false)
|
||||
@secret = secret
|
||||
@deterministic = deterministic
|
||||
end
|
||||
|
||||
def encrypt(clear_text)
|
||||
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
|
||||
cipher.encrypt
|
||||
cipher.key = @secret
|
||||
|
||||
iv = generate_iv(cipher, clear_text)
|
||||
cipher.iv = iv
|
||||
|
||||
encrypted_data = clear_text.empty? ? clear_text : cipher.update(clear_text)
|
||||
encrypted_data << cipher.final
|
||||
|
||||
ActiveRecord::Encryption::Message.new(payload: encrypted_data).tap do |message|
|
||||
message.headers.iv = iv
|
||||
message.headers.auth_tag = cipher.auth_tag
|
||||
end
|
||||
end
|
||||
|
||||
def decrypt(encrypted_message)
|
||||
encrypted_data = encrypted_message.payload
|
||||
iv = encrypted_message.headers.iv
|
||||
auth_tag = encrypted_message.headers.auth_tag
|
||||
|
||||
# Currently the OpenSSL bindings do not raise an error if auth_tag is
|
||||
# truncated, which would allow an attacker to easily forge it. See
|
||||
# https://github.com/ruby/openssl/issues/63
|
||||
raise ActiveRecord::Encryption::Errors::EncryptedContentIntegrity if auth_tag.nil? || auth_tag.bytes.length != 16
|
||||
|
||||
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
|
||||
|
||||
cipher.decrypt
|
||||
cipher.key = @secret
|
||||
cipher.iv = iv
|
||||
|
||||
cipher.auth_tag = auth_tag
|
||||
cipher.auth_data = ""
|
||||
|
||||
decrypted_data = encrypted_data.empty? ? encrypted_data : cipher.update(encrypted_data)
|
||||
decrypted_data << cipher.final
|
||||
|
||||
decrypted_data
|
||||
rescue OpenSSL::Cipher::CipherError, TypeError, ArgumentError
|
||||
raise ActiveRecord::Encryption::Errors::Decryption
|
||||
end
|
||||
|
||||
private
|
||||
def generate_iv(cipher, clear_text)
|
||||
if @deterministic
|
||||
generate_deterministic_iv(clear_text)
|
||||
else
|
||||
cipher.random_iv
|
||||
end
|
||||
end
|
||||
|
||||
def generate_deterministic_iv(clear_text)
|
||||
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @secret, clear_text)[0, ActiveRecord::Encryption.cipher.iv_length]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# Container of contfiguration options
|
||||
class Config
|
||||
attr_accessor :master_key, :deterministic_key, :store_key_references, :key_derivation_salt,
|
||||
:support_unencrypted_data, :encrypt_fixtures, :validate_column_size, :add_to_filter_parameters,
|
||||
:excluded_from_filter_parameters
|
||||
|
||||
def initialize
|
||||
set_defaults
|
||||
end
|
||||
|
||||
private
|
||||
def set_defaults
|
||||
self.store_key_references = false
|
||||
self.support_unencrypted_data = false
|
||||
self.encrypt_fixtures = false
|
||||
self.validate_column_size = true
|
||||
self.add_to_filter_parameters = true
|
||||
self.excluded_from_filter_parameters = []
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# Configuration API for +ActiveRecord::Encryption+
|
||||
module Configurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
mattr_reader :config, default: Config.new
|
||||
mattr_accessor :encrypted_attribute_declaration_listeners
|
||||
end
|
||||
|
||||
class_methods do
|
||||
# Expose getters for context properties
|
||||
Context::PROPERTIES.including(:encryptor).each do |name|
|
||||
delegate name, to: :context
|
||||
end
|
||||
|
||||
def configure(master_key:, deterministic_key:, key_derivation_salt:, **properties) #:nodoc:
|
||||
config.master_key = master_key
|
||||
config.deterministic_key = deterministic_key
|
||||
config.key_derivation_salt = key_derivation_salt
|
||||
|
||||
context.key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(master_key)
|
||||
|
||||
properties.each do |name, value|
|
||||
[:context, :config].each do |configurable_object_name|
|
||||
configurable_object = ActiveRecord::Encryption.send(configurable_object_name)
|
||||
configurable_object.send "#{name}=", value if configurable_object.respond_to?(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Register callback to be invoked when an encrypted attribute is declared.
|
||||
#
|
||||
# === Example:
|
||||
#
|
||||
# ActiveRecord::Encryption.on_encrypted_attribute_declared do |klass, attribute_name|
|
||||
# ...
|
||||
# end
|
||||
def on_encrypted_attribute_declared(&block)
|
||||
self.encrypted_attribute_declaration_listeners ||= Concurrent::Array.new
|
||||
self.encrypted_attribute_declaration_listeners << block
|
||||
end
|
||||
|
||||
def encrypted_attribute_was_declared(klass, name) #:nodoc:
|
||||
self.encrypted_attribute_declaration_listeners&.each do |block|
|
||||
block.call(klass, name)
|
||||
end
|
||||
end
|
||||
|
||||
def install_auto_filtered_parameters(application) #:nodoc:
|
||||
ActiveRecord::Encryption.on_encrypted_attribute_declared do |klass, encrypted_attribute_name|
|
||||
application.config.filter_parameters << encrypted_attribute_name unless ActiveRecord::Encryption.config.excluded_from_filter_parameters.include?(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,33 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# An encryption context configures the different entities used to perform encryption:
|
||||
#
|
||||
# * A key provider
|
||||
# * A key generator
|
||||
# * An encryptor, the facade to encrypt data
|
||||
# * A cipher, the encryption algorithm
|
||||
# * A message serializer
|
||||
class Context
|
||||
PROPERTIES = %i[ key_provider key_generator cipher message_serializer encryptor frozen_encryption ]
|
||||
|
||||
PROPERTIES.each do |name|
|
||||
attr_accessor name
|
||||
end
|
||||
|
||||
def initialize
|
||||
set_defaults
|
||||
end
|
||||
|
||||
alias frozen_encryption? frozen_encryption
|
||||
|
||||
private
|
||||
def set_defaults
|
||||
self.frozen_encryption = false
|
||||
self.key_generator = ActiveRecord::Encryption::KeyGenerator.new
|
||||
self.cipher = ActiveRecord::Encryption::Cipher.new
|
||||
self.encryptor = ActiveRecord::Encryption::Encryptor.new
|
||||
self.message_serializer = ActiveRecord::Encryption::MessageSerializer.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,70 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# +ActiveRecord::Encryption+ uses encryption contexts to configure the different entities used to
|
||||
# encrypt/decrypt at a given moment in time.
|
||||
#
|
||||
# By default, the library uses a default encryption context. This is the +Context+ that gets configured
|
||||
# initially via +config.active_record.encryption+ options. Library users can define nested encryption contexts
|
||||
# when running blocks of code.
|
||||
#
|
||||
# See +Context+.
|
||||
module Contexts
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
mattr_reader :default_context, default: Context.new
|
||||
thread_mattr_accessor :custom_contexts
|
||||
end
|
||||
|
||||
class_methods do
|
||||
# Configures a custom encryption context to use when running the provided block of code
|
||||
#
|
||||
# It supports overriding all the properties defined in +Context+.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# ActiveRecord::Encryption.with_encryption_context(encryptor: ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
# ...
|
||||
# end
|
||||
#
|
||||
# Encryption contexts can be nested.
|
||||
def with_encryption_context(properties)
|
||||
self.custom_contexts ||= []
|
||||
self.custom_contexts << default_context.dup
|
||||
properties.each do |key, value|
|
||||
self.current_custom_context.send("#{key}=", value)
|
||||
end
|
||||
|
||||
yield
|
||||
ensure
|
||||
self.custom_contexts.pop
|
||||
end
|
||||
|
||||
# Runs the provided block in an encryption context where encryption is disabled:
|
||||
#
|
||||
# * Reading encrypted content will return its ciphertexts.
|
||||
# * Writing encrypted content will write its clear text.
|
||||
def without_encryption(&block)
|
||||
with_encryption_context encryptor: ActiveRecord::Encryption::NullEncryptor.new, &block
|
||||
end
|
||||
|
||||
# Runs the provided block in an encryption context where:
|
||||
#
|
||||
# * Reading encrypted content will return its ciphertext.
|
||||
# * Writing encrypted content will fail.
|
||||
def protecting_encrypted_data(&block)
|
||||
with_encryption_context encryptor: ActiveRecord::Encryption::EncryptingOnlyEncryptor.new, frozen_encryption: true, &block
|
||||
end
|
||||
|
||||
# Returns the current context. By default it will return the current context.
|
||||
def context
|
||||
self.current_custom_context || self.default_context
|
||||
end
|
||||
|
||||
def current_custom_context
|
||||
self.custom_contexts&.last
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A +KeyProvider+ that derives keys from passwords
|
||||
class DerivedSecretKeyProvider < KeyProvider
|
||||
def initialize(passwords)
|
||||
super(Array(passwords).collect { |password| Key.derive_from(password) })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,222 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# This is the concern mixed in Active Record models to make them encryptable. It adds the +encrypts+
|
||||
# attribute declaration, as well as the API to encrypt and decrypt records.
|
||||
module EncryptableRecord
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
class_attribute :encrypted_attributes
|
||||
|
||||
validate :cant_modify_encrypted_attributes_when_frozen, if: -> { has_encrypted_attributes? && ActiveRecord::Encryption.context.frozen_encryption? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
# Encrypts the +name+ attribute.
|
||||
#
|
||||
# === Options
|
||||
#
|
||||
# * <tt>:key_provider</tt> - Configure a +KeyProvider+ for serving the keys to encrypt and
|
||||
# decrypt this attribute. If not provided, it will default to +ActiveRecord::Encryption.key_provider+.
|
||||
# * <tt>:key</tt> - A password to derive the key from. It's a shorthand for a +:key_provider+ that
|
||||
# serves derivated keys. Both options can't be used at the same time.
|
||||
# * <tt>:key_provider</tt> - Set a +:key_provider+ to provide encryption and decryption keys. If not
|
||||
# provided, it will default to the key provider set with `config.key_provider`.
|
||||
# * <tt>:deterministic</tt> - By default, encryption is not deterministic. It will use a random
|
||||
# initialization vector for each encryption operation. This means that encrypting the same content
|
||||
# with the same key twice will generate different ciphertexts. When set to +true+, it will generate the
|
||||
# initialization vector based on the encrypted content. This means that the same content will generate
|
||||
# the same ciphertexts. This enables querying encrypted text with Active Record.
|
||||
# * <tt>:downcase</tt> - When true, it converts the encrypted content to downcase automatically. This allows to
|
||||
# effectively ignore case when querying data. Notice that the case is lost. Use +:ignore_case+ if you are interested
|
||||
# in preserving it.
|
||||
# * <tt>:ignore_case</tt> - When true, it behaves like +:downcase+ but, it also preserves the original case in a specially
|
||||
# designated column +original_<name>+. When reading the encrypted content, the version with the original case is
|
||||
# server. But you can still execute queries that will ignore the case. This option can only be used when +:deterministic+
|
||||
# is true.
|
||||
# * <tt>:previous</tt> -
|
||||
def encrypts(*names, key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, context: nil, previous: [])
|
||||
self.encrypted_attributes ||= Set.new # not using :default because the instance would be shared across classes
|
||||
|
||||
names.each do |name|
|
||||
encrypt_attribute name, key_provider: key_provider, key: key, deterministic: deterministic, downcase: downcase,
|
||||
ignore_case: ignore_case, subtype: type_for_attribute(name), context: context, previous: previous
|
||||
validate_column_size(name) if ActiveRecord::Encryption.config.validate_column_size
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the list of deterministic encryptable attributes in the model class.
|
||||
def deterministic_encrypted_attributes
|
||||
@deterministic_encrypted_attributes ||= encrypted_attributes&.find_all do |attribute_name|
|
||||
type_for_attribute(attribute_name).deterministic?
|
||||
end
|
||||
end
|
||||
|
||||
# Given a attribute name, it returns the name of the source attribute when it's a preserved one
|
||||
def source_attribute_from_preserved_attribute(attribute_name)
|
||||
attribute_name.to_s.sub(ORIGINAL_ATTRIBUTE_PREFIX, "") if /^#{ORIGINAL_ATTRIBUTE_PREFIX}/.match?(attribute_name)
|
||||
end
|
||||
|
||||
private
|
||||
def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, downcase: false,
|
||||
ignore_case: false, subtype: ActiveModel::Type::String.new, context: nil, previous: [])
|
||||
raise Errors::Configuration, ":ignore_case can only be used with deterministic encryption" if ignore_case && !deterministic
|
||||
raise Errors::Configuration, ":key_provider and :key can't be used simultaneously" if key_provider && key
|
||||
|
||||
encrypted_attributes << name.to_sym
|
||||
|
||||
key_provider = build_key_provider(key_provider: key_provider, key: key, deterministic: deterministic)
|
||||
|
||||
attribute name, :encrypted, key_provider: key_provider, downcase: downcase || ignore_case, deterministic: deterministic,
|
||||
subtype: subtype, context: context, previous_types: build_previous_types(previous, subtype)
|
||||
preserve_original_encrypted(name) if ignore_case
|
||||
ActiveRecord::Encryption.encrypted_attribute_was_declared(self, name)
|
||||
end
|
||||
|
||||
def build_previous_types(previous_config_list, type)
|
||||
previous_config_list = [previous_config_list] unless previous_config_list.is_a?(Array)
|
||||
previous_config_list.collect do |previous_config|
|
||||
key_provider = build_key_provider(**previous_config.slice(:key_provider, :key, :deterministic))
|
||||
ActiveRecord::Encryption::EncryptedAttributeType.new \
|
||||
key_provider: key_provider, downcase: previous_config[:downcase] || previous_config[:ignore_case],
|
||||
deterministic: previous_config[:deterministic], context: previous_config[:context], subtype: type
|
||||
end
|
||||
end
|
||||
|
||||
def build_key_provider(key_provider: nil, key: nil, deterministic: false)
|
||||
return DerivedSecretKeyProvider.new(key) if key.present?
|
||||
return key_provider if key_provider
|
||||
|
||||
if deterministic && (deterministic_key = ActiveRecord::Encryption.config.deterministic_key)
|
||||
DerivedSecretKeyProvider.new(deterministic_key)
|
||||
end
|
||||
end
|
||||
|
||||
def preserve_original_encrypted(name)
|
||||
original_attribute_name = "#{ORIGINAL_ATTRIBUTE_PREFIX}#{name}".to_sym
|
||||
|
||||
if !ActiveRecord::Encryption.config.support_unencrypted_data && !column_names.include?(original_attribute_name.to_s)
|
||||
raise Errors::Configuration, "To use :ignore_case for '#{name}' you must create an additional column named '#{original_attribute_name}'"
|
||||
end
|
||||
|
||||
encrypts original_attribute_name
|
||||
|
||||
define_method name do
|
||||
if ((value = super()) && encrypted_attribute?(name)) || !ActiveRecord::Encryption.config.support_unencrypted_data
|
||||
send(original_attribute_name)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
define_method "#{name}=" do |value|
|
||||
self.send "#{original_attribute_name}=", value
|
||||
super(value)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_column_size(attribute_name)
|
||||
if limit = connection.schema_cache.columns_hash(table_name)[attribute_name.to_s]&.limit
|
||||
validates_length_of attribute_name, maximum: limit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns whether a given attribute is encrypted or not
|
||||
def encrypted_attribute?(attribute_name)
|
||||
ActiveRecord::Encryption.encryptor.encrypted? ciphertext_for(attribute_name)
|
||||
end
|
||||
|
||||
# Returns the ciphertext for +attribute_name+
|
||||
def ciphertext_for(attribute_name)
|
||||
read_attribute_before_type_cast(attribute_name)
|
||||
end
|
||||
|
||||
# Encrypts all the encryptable attributes and saves the model
|
||||
#
|
||||
# === Options
|
||||
#
|
||||
# * <tt>:skip_rich_texts</tt> - Configure if you want to ignore action text attributes
|
||||
# when encrypting the record. It's false by default.
|
||||
#
|
||||
# Encrypting action text requires performing additional queries to fetch the rich text
|
||||
# records. This is a performance setting to avoid those queries when possible.
|
||||
def encrypt(skip_rich_texts: false)
|
||||
transaction do
|
||||
encrypt_attributes if has_encrypted_attributes?
|
||||
encrypt_rich_texts if !skip_rich_texts && has_encrypted_rich_texts?
|
||||
end
|
||||
end
|
||||
|
||||
# Decrypts all the encryptable attributes and saves the model
|
||||
def decrypt
|
||||
transaction do
|
||||
decrypt_attributes if has_encrypted_attributes?
|
||||
decrypt_rich_texts if has_encrypted_rich_texts?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
ORIGINAL_ATTRIBUTE_PREFIX = "original_"
|
||||
|
||||
def encrypt_attributes
|
||||
update_columns build_encrypt_attribute_assignments
|
||||
end
|
||||
|
||||
def decrypt_attributes
|
||||
decrypt_attribute_assignments = build_decrypt_attribute_assignments
|
||||
ActiveRecord::Encryption.without_encryption { update_columns decrypt_attribute_assignments }
|
||||
end
|
||||
|
||||
def has_encrypted_attributes?
|
||||
self.class.encrypted_attributes.present?
|
||||
end
|
||||
|
||||
def has_encrypted_rich_texts?
|
||||
encryptable_rich_texts.present?
|
||||
end
|
||||
|
||||
def build_encrypt_attribute_assignments
|
||||
Array(self.class.encrypted_attributes).index_with do |attribute_name|
|
||||
if source_attribute_name = self.class.source_attribute_from_preserved_attribute(attribute_name)
|
||||
self[source_attribute_name]
|
||||
else
|
||||
self[attribute_name]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_decrypt_attribute_assignments
|
||||
Array(self.class.encrypted_attributes).collect do |attribute_name|
|
||||
type = type_for_attribute(attribute_name)
|
||||
encrypted_value = ciphertext_for(attribute_name)
|
||||
new_value = type.deserialize(encrypted_value)
|
||||
[attribute_name, new_value]
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def encrypt_rich_texts
|
||||
encryptable_rich_texts.each(&:encrypt)
|
||||
end
|
||||
|
||||
def decrypt_rich_texts
|
||||
encryptable_rich_texts.each(&:decrypt)
|
||||
end
|
||||
|
||||
def encryptable_rich_texts
|
||||
@encryptable_rich_texts ||= self.class
|
||||
.reflect_on_all_associations(:has_one)
|
||||
.collect(&:name)
|
||||
.grep(/rich_text/)
|
||||
.collect { |attribute_name| send(attribute_name) }.compact
|
||||
.find_all { |record| record.class.name == "ActionText::EncryptedRichText" } # not using class check to avoid adding dependency
|
||||
end
|
||||
|
||||
def cant_modify_encrypted_attributes_when_frozen
|
||||
self.class&.encrypted_attributes.each do |attribute|
|
||||
errors.add(attribute.to_sym, "can't be modified because it is encrypted") if changed_attributes.include?(attribute)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,98 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# An +ActiveModel::Type+ that encrypts/decrypts strings of text
|
||||
#
|
||||
# This is the central piece that connects the encryption system with +encrypts+ declarations in the
|
||||
# model classes. Whenever you declare an attribute as encrypted, it configures an +EncryptedAttributeType+
|
||||
# for that attribute.
|
||||
class EncryptedAttributeType < ::ActiveRecord::Type::Text
|
||||
include ActiveModel::Type::Helpers::Mutable
|
||||
|
||||
attr_reader :key_provider, :previous_types, :subtype, :downcase
|
||||
|
||||
def initialize(key_provider: nil, deterministic: false, downcase: false, subtype: ActiveModel::Type::String.new, context: nil, previous_types: [])
|
||||
super()
|
||||
@key_provider = key_provider
|
||||
@deterministic = deterministic
|
||||
@downcase = downcase
|
||||
@subtype = subtype
|
||||
@previous_types = previous_types
|
||||
@context = context
|
||||
end
|
||||
|
||||
def deserialize(value)
|
||||
@subtype.deserialize decrypt(value)
|
||||
end
|
||||
|
||||
def serialize(value)
|
||||
casted_value = @subtype.serialize(value)
|
||||
casted_value = casted_value&.downcase if @downcase
|
||||
encrypt(casted_value.to_s) unless casted_value.nil? # Object values without a proper serializer get converted with #to_s
|
||||
end
|
||||
|
||||
def changed_in_place?(raw_old_value, new_value)
|
||||
old_value = raw_old_value.nil? ? nil : deserialize(raw_old_value)
|
||||
old_value != new_value
|
||||
end
|
||||
|
||||
def deterministic?
|
||||
@deterministic
|
||||
end
|
||||
|
||||
private
|
||||
def decrypt(value)
|
||||
with_context do
|
||||
encryptor.decrypt(value, **decryption_options) unless value.nil?
|
||||
end
|
||||
rescue ActiveRecord::Encryption::Errors::Base => error
|
||||
if previous_types.blank?
|
||||
handle_deserialize_error(error, value)
|
||||
else
|
||||
try_to_deserialize_with_previous_types(value)
|
||||
end
|
||||
end
|
||||
|
||||
def try_to_deserialize_with_previous_types(value)
|
||||
previous_types.each.with_index do |type, index|
|
||||
break type.deserialize(value)
|
||||
rescue ActiveRecord::Encryption::Errors::Base => error
|
||||
handle_deserialize_error(error, value) if index == previous_types.length - 1
|
||||
end
|
||||
end
|
||||
|
||||
def handle_deserialize_error(error, value)
|
||||
if error.is_a?(Errors::Decryption) && ActiveRecord::Encryption.config.support_unencrypted_data
|
||||
value
|
||||
else
|
||||
raise error
|
||||
end
|
||||
end
|
||||
|
||||
def encrypt(value)
|
||||
with_context do
|
||||
encryptor.encrypt(value, **encryption_options)
|
||||
end
|
||||
end
|
||||
|
||||
def encryptor
|
||||
ActiveRecord::Encryption.encryptor
|
||||
end
|
||||
|
||||
def encryption_options
|
||||
@encryption_options ||= { key_provider: @key_provider, cipher_options: { deterministic: @deterministic } }.compact
|
||||
end
|
||||
|
||||
def decryption_options
|
||||
@decryption_options ||= { key_provider: @key_provider }.compact
|
||||
end
|
||||
|
||||
def with_context(&block)
|
||||
if @context
|
||||
ActiveRecord::Encryption.with_encryption_context(**@context, &block)
|
||||
else
|
||||
block.call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,37 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# Encrypts encryptable columns when loading fixtures automatically
|
||||
module EncryptedFixtures
|
||||
def initialize(fixture, model_class)
|
||||
@clean_values = {}
|
||||
encrypt_fixture_data(fixture, model_class)
|
||||
process_preserved_original_columns(fixture, model_class)
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
def encrypt_fixture_data(fixture, model_class)
|
||||
model_class&.encrypted_attributes&.each do |attribute_name|
|
||||
if clean_value = fixture[attribute_name.to_s]
|
||||
@clean_values[attribute_name.to_s] = clean_value
|
||||
|
||||
type = model_class.type_for_attribute(attribute_name)
|
||||
encrypted_value = type.serialize(clean_value)
|
||||
fixture[attribute_name.to_s] = encrypted_value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def process_preserved_original_columns(fixture, model_class)
|
||||
model_class&.encrypted_attributes&.each do |attribute_name|
|
||||
if source_attribute_name = model_class.source_attribute_from_preserved_attribute(attribute_name)
|
||||
clean_value = @clean_values[source_attribute_name.to_s]
|
||||
type = model_class.type_for_attribute(attribute_name)
|
||||
encrypted_value = type.serialize(clean_value)
|
||||
fixture[attribute_name.to_s] = encrypted_value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# An encryptor that can encrypt data but can't decrypt it
|
||||
class EncryptingOnlyEncryptor < Encryptor
|
||||
def decrypt(encrypted_text, key_provider: nil, cipher_options: {})
|
||||
encrypted_text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,138 @@
|
|||
require "openssl"
|
||||
require "zip"
|
||||
require "active_support/core_ext/numeric"
|
||||
|
||||
module ActiveRecord
|
||||
module Encryption
|
||||
# An encryptor is the internal facade for encrypting and decrypting data.
|
||||
#
|
||||
# It interacts with a +KeyProvider+ for getting the keys, and delegate to
|
||||
# +ActiveRecord::Encryption::Cipher+ the actual encryption algorithm.
|
||||
class Encryptor
|
||||
# Encrypts +clean_text+ and returns the encrypted result
|
||||
#
|
||||
# Internally, it will:
|
||||
#
|
||||
# 1. Create a new +ActiveRecord::Encryption::Message+
|
||||
# 2. Compress and encrypt +clean_text+ as the message payload
|
||||
# 3. Serialize it with +ActiveRecord::Encryption.message_serializer+ (+ActiveRecord::Encryption::SafeMarhsal+
|
||||
# by default)
|
||||
# 4. Encode the result with Base 64
|
||||
#
|
||||
# === Options
|
||||
#
|
||||
# [:key_provider]
|
||||
# Key provider to use for the encryption operation. It will default to
|
||||
# +ActiveRecord::Encryption.key_provider+ when not provided
|
||||
#
|
||||
# [:cipher_options]
|
||||
# +Cipher+-specific options that will be passed to the Cipher configured in
|
||||
# +ActiveRecord::Encryption.cipher+
|
||||
def encrypt(clear_text, key_provider: default_key_provider, cipher_options: {})
|
||||
validate_payload_type(clear_text)
|
||||
serialize_message build_encrypted_message(clear_text, key_provider: key_provider, cipher_options: cipher_options)
|
||||
end
|
||||
|
||||
# Decrypts a +clean_text+ and returns the result as clean text
|
||||
#
|
||||
# === Options
|
||||
#
|
||||
# [:key_provider]
|
||||
# Key provider to use for the encryption operation. It will default to
|
||||
# +ActiveRecord::Encryption.key_provider+ when not provided
|
||||
#
|
||||
# [:cipher_options]
|
||||
# +Cipher+-specific options that will be passed to the Cipher configured in
|
||||
# +ActiveRecord::Encryption.cipher+
|
||||
def decrypt(encrypted_text, key_provider: default_key_provider, cipher_options: {})
|
||||
message = deserialize_message(encrypted_text)
|
||||
keys = key_provider.decryption_keys(message)
|
||||
raise Errors::Decryption unless keys.present?
|
||||
uncompress_if_needed(cipher.decrypt(message, key: keys.collect(&:secret), **cipher_options), message.headers.compressed)
|
||||
rescue *(ENCODING_ERRORS + DECRYPT_ERRORS)
|
||||
raise Errors::Decryption
|
||||
end
|
||||
|
||||
# Returns whether the text is encrypted or not
|
||||
def encrypted?(text)
|
||||
deserialize_message(text)
|
||||
true
|
||||
rescue Errors::Encoding, *DECRYPT_ERRORS
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
DECRYPT_ERRORS = [OpenSSL::Cipher::CipherError, Errors::EncryptedContentIntegrity, Errors::Decryption]
|
||||
ENCODING_ERRORS = [EncodingError, Errors::Encoding]
|
||||
THRESHOLD_TO_JUSTIFY_COMPRESSION = 140.bytes
|
||||
|
||||
def default_key_provider
|
||||
ActiveRecord::Encryption.key_provider
|
||||
end
|
||||
|
||||
def validate_payload_type(clear_text)
|
||||
unless clear_text.is_a?(String)
|
||||
raise ActiveRecord::Encryption::Errors::ForbiddenClass, "The encryptor can only encrypt string values (#{clear_text.class})"
|
||||
end
|
||||
end
|
||||
|
||||
def cipher
|
||||
ActiveRecord::Encryption.cipher
|
||||
end
|
||||
|
||||
def build_encrypted_message(clear_text, key_provider:, cipher_options:)
|
||||
key = key_provider.encryption_key
|
||||
|
||||
clear_text, was_compressed = compress_if_worth_it(clear_text)
|
||||
cipher.encrypt(clear_text, key: key.secret, **cipher_options).tap do |message|
|
||||
message.headers.add(key.public_tags)
|
||||
message.headers.compressed = true if was_compressed
|
||||
end
|
||||
end
|
||||
|
||||
def serialize_message(message)
|
||||
serializer.dump(message)
|
||||
end
|
||||
|
||||
def deserialize_message(message)
|
||||
raise Errors::Encoding unless message.is_a?(String)
|
||||
serializer.load message
|
||||
rescue ArgumentError, TypeError, Errors::ForbiddenClass
|
||||
raise Errors::Encoding
|
||||
end
|
||||
|
||||
def serializer
|
||||
ActiveRecord::Encryption.message_serializer
|
||||
end
|
||||
|
||||
# Under certain threshold, ZIP compression is actually worse that not compressing
|
||||
def compress_if_worth_it(string)
|
||||
if string.bytesize > THRESHOLD_TO_JUSTIFY_COMPRESSION
|
||||
[compress(string), true]
|
||||
else
|
||||
[string, false]
|
||||
end
|
||||
end
|
||||
|
||||
def compress(data)
|
||||
Zlib::Deflate.deflate(data).tap do |compressed_data|
|
||||
compressed_data.force_encoding(data.encoding)
|
||||
end
|
||||
end
|
||||
|
||||
def uncompress_if_needed(data, compressed)
|
||||
if compressed
|
||||
uncompress(data)
|
||||
else
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
def uncompress(data)
|
||||
Zlib::Inflate.inflate(data).tap do |uncompressed_data|
|
||||
uncompressed_data.force_encoding(data.encoding)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,53 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# Implements a simple envelope encryption approach where:
|
||||
#
|
||||
# * It generates a random data-encryption key for each encryption operation
|
||||
# * It stores the generated key along with the encrypted payload. It encrypts this key
|
||||
# with the master key provided in the credential +active_record.encryption.master key+
|
||||
#
|
||||
# This provider can work with multiple master keys. It will use the first one for encrypting.
|
||||
#
|
||||
# When `config.store_key_references` is true, it will also store a reference to
|
||||
# the specific master key that was used to encrypt the data-encryption key. When not set,
|
||||
# it will try all the configured master keys looking for the right one, in order to
|
||||
# return the right decryption key.
|
||||
class EnvelopeEncryptionKeyProvider
|
||||
def encryption_key
|
||||
random_secret = generate_random_secret
|
||||
ActiveRecord::Encryption::Key.new(random_secret).tap do |key|
|
||||
key.public_tags.encrypted_data_key = encrypt_data_key(random_secret)
|
||||
key.public_tags.encrypted_data_key_id = active_master_key.id if ActiveRecord::Encryption.config.store_key_references
|
||||
end
|
||||
end
|
||||
|
||||
def decryption_keys(encrypted_message)
|
||||
secret = decrypt_data_key(encrypted_message)
|
||||
secret ? [ActiveRecord::Encryption::Key.new(secret)] : []
|
||||
end
|
||||
|
||||
def active_master_key
|
||||
@active_master_key ||= master_key_provider.encryption_key
|
||||
end
|
||||
|
||||
private
|
||||
def encrypt_data_key(random_secret)
|
||||
ActiveRecord::Encryption.cipher.encrypt(random_secret, key: active_master_key.secret)
|
||||
end
|
||||
|
||||
def decrypt_data_key(encrypted_message)
|
||||
encrypted_data_key = encrypted_message.headers.encrypted_data_key
|
||||
key = master_key_provider.decryption_keys(encrypted_message)&.collect(&:secret)
|
||||
ActiveRecord::Encryption.cipher.decrypt encrypted_data_key, key: key if key
|
||||
end
|
||||
|
||||
def master_key_provider
|
||||
@master_key_provider ||= DerivedSecretKeyProvider.new(ActiveRecord::Encryption.config.master_key)
|
||||
end
|
||||
|
||||
def generate_random_secret
|
||||
ActiveRecord::Encryption.key_generator.generate_random_key
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
module Errors
|
||||
class Base < StandardError; end
|
||||
class Encoding < Base; end
|
||||
class Decryption < Base; end
|
||||
class Encryption < Base; end
|
||||
class Configuration < Base; end
|
||||
class ForbiddenClass < Base; end
|
||||
class EncryptedContentIntegrity < Base; end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,138 @@
|
|||
# Automatically expand encrypted arguments to support querying both encrypted and unencrypted data
|
||||
#
|
||||
# Active Record Encryption supports querying the db using deterministic attributes. For example:
|
||||
#
|
||||
# Contact.find_by(email_address: "jorge@hey.com")
|
||||
#
|
||||
# The value "jorge@hey.com" will get encrypted automatically to perform the query. But there is
|
||||
# a problem while the data is being encrypted. This won't work. During that time, you need these
|
||||
# queries to be:
|
||||
#
|
||||
# Contact.find_by(email_address: [ "jorge@hey.com", "<encrypted jorge@hey.com>" ])
|
||||
#
|
||||
# This patches ActiveRecord to support this automatically. It addresses both:
|
||||
#
|
||||
# * ActiveRecord::Base: Used in +Contact.find_by_email_address(...)+
|
||||
# * ActiveRecord::Relation: Used in +Contact.internal.find_by_email_address(...)+
|
||||
#
|
||||
# +ActiveRecord::Base+ relies on +ActiveRecord::Relation+ (+ActiveRecord::QueryMethods+) but it does
|
||||
# some prepared statements caching. That's why we need to intercept +ActiveRecord::Base+ as soon
|
||||
# as it's invoked (so that the proper prepared statement is cached).
|
||||
#
|
||||
# When modifying this file run performance tests in +test/performance/extended_deterministic_queries_performance_test.rb+ to
|
||||
# make sure performance overhead is acceptable.
|
||||
#
|
||||
# We will extend this to support previous "encryption context" versions in future iterations
|
||||
#
|
||||
# @todo This is experimental stuff. Works for our cases but full support for every kind of query is pending
|
||||
module ActiveRecord
|
||||
module Encryption
|
||||
module ExtendedDeterministicQueries
|
||||
def self.install_support
|
||||
ActiveRecord::Relation.prepend(RelationQueries)
|
||||
ActiveRecord::Base.include(CoreQueries)
|
||||
ActiveRecord::Encryption::EncryptedAttributeType.prepend(ExtendedEncryptableType)
|
||||
end
|
||||
|
||||
module EncryptedQueryArgumentProcessor
|
||||
private
|
||||
def process_encrypted_query_arguments(args, check_for_skipped_values)
|
||||
if args.is_a?(Array) && (options = args.first).is_a?(Hash)
|
||||
self.deterministic_encrypted_attributes&.each do |attribute_name|
|
||||
type = type_for_attribute(attribute_name)
|
||||
if value = options[attribute_name]
|
||||
options[attribute_name] = process_encrypted_query_argument(value, check_for_skipped_values, type)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def process_encrypted_query_argument(value, check_for_skipped_values, type)
|
||||
return value if check_for_skipped_values && value.is_a?(Array) && value.last.is_a?(AdditionalValue)
|
||||
|
||||
case value
|
||||
when String, Array
|
||||
list = Array(value)
|
||||
list + list.flat_map do |each_value|
|
||||
if check_for_skipped_values && each_value.is_a?(AdditionalValue)
|
||||
each_value
|
||||
else
|
||||
additional_values_for(each_value, type)
|
||||
end
|
||||
end
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def additional_values_for(value, type)
|
||||
type.previous_types.including(clean_text_type_for(type)).collect do |additional_type|
|
||||
AdditionalValue.new(value, additional_type)
|
||||
end
|
||||
end
|
||||
|
||||
def clean_text_type_for(type)
|
||||
ActiveRecord::Encryption::EncryptedAttributeType.new(downcase: type.downcase, context: { encryptor: null_encryptor })
|
||||
end
|
||||
|
||||
def null_encryptor
|
||||
@null_encryptor ||= ActiveRecord::Encryption::NullEncryptor.new
|
||||
end
|
||||
end
|
||||
|
||||
module RelationQueries
|
||||
include EncryptedQueryArgumentProcessor
|
||||
|
||||
def where(*args)
|
||||
process_encrypted_query_arguments(args, true) unless self.deterministic_encrypted_attributes&.empty?
|
||||
super
|
||||
end
|
||||
|
||||
def find_or_create_by(attributes, &block)
|
||||
find_by(attributes.dup) || create(attributes, &block)
|
||||
end
|
||||
|
||||
def find_or_create_by!(attributes, &block)
|
||||
find_by(attributes.dup) || create!(attributes, &block)
|
||||
end
|
||||
end
|
||||
|
||||
module CoreQueries
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
include EncryptedQueryArgumentProcessor
|
||||
|
||||
def find_by(*args)
|
||||
process_encrypted_query_arguments(args, false) unless self.deterministic_encrypted_attributes&.empty?
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class AdditionalValue
|
||||
attr_reader :value, :type
|
||||
|
||||
def initialize(value, type)
|
||||
@type = type
|
||||
@value = process(value)
|
||||
end
|
||||
|
||||
private
|
||||
def process(value)
|
||||
type.serialize(value)
|
||||
end
|
||||
end
|
||||
|
||||
module ExtendedEncryptableType
|
||||
def serialize(data)
|
||||
if data.is_a?(AdditionalValue)
|
||||
data.value
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,26 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A key is a container for a given +secret+
|
||||
#
|
||||
# Optionally, it can include +public_tags+. These tags are meant to be stored
|
||||
# in clean (public) and can be used, for example, to include information that
|
||||
# references the key for a future retrieval operation.
|
||||
class Key
|
||||
attr_reader :secret, :public_tags
|
||||
|
||||
def initialize(secret)
|
||||
@secret = secret
|
||||
@public_tags = Properties.new
|
||||
end
|
||||
|
||||
def self.derive_from(password)
|
||||
secret = ActiveRecord::Encryption.key_generator.derive_key_from(password)
|
||||
ActiveRecord::Encryption::Key.new(secret)
|
||||
end
|
||||
|
||||
def id
|
||||
Digest::SHA1.hexdigest(secret).first(4)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,40 @@
|
|||
require "securerandom"
|
||||
|
||||
module ActiveRecord
|
||||
module Encryption
|
||||
# Utility for generating and deriving random keys.
|
||||
class KeyGenerator
|
||||
# Returns a random key. The key will have a size in bytes of +:length+ (configured +Cipher+'s length by default)
|
||||
def generate_random_key(length: key_length)
|
||||
SecureRandom.random_bytes(length)
|
||||
end
|
||||
|
||||
# Returns a random key in hexadecimal format. The key will have a size in bytes of +:length+ (configured +Cipher+'s
|
||||
# lenght by default)
|
||||
#
|
||||
# Hexadecimal format is handy for representing keys as printable text. To maximize the space of characters used, it is
|
||||
# good practice including not printable characters. Hexadecimal format ensures that generated keys are representable with
|
||||
# plain text
|
||||
#
|
||||
# To convert back to the original string with the desired length:
|
||||
#
|
||||
# [ value ].pack("H*")
|
||||
def generate_random_hex_key(length: key_length)
|
||||
generate_random_key(length: length).unpack("H*")[0]
|
||||
end
|
||||
|
||||
# Derives a key from the given password. The key will have a size in bytes of +:length+ (configured +Cipher+'s length
|
||||
# by default)
|
||||
#
|
||||
# The generated key will be salted with the value of +ActiveRecord::Encryption.key_derivation_salt+
|
||||
def derive_key_from(password, length: key_length)
|
||||
ActiveSupport::KeyGenerator.new(password).generate_key(ActiveRecord::Encryption.config.key_derivation_salt, length)
|
||||
end
|
||||
|
||||
private
|
||||
def key_length
|
||||
@key_length ||= ActiveRecord::Encryption.cipher.key_length
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A +KeyProvider+ serves keys:
|
||||
#
|
||||
# * An encryption key
|
||||
# * A list of potential decryption keys. Serving multiple decryption keys supports rotation-schemes
|
||||
# where new keys are added but old keys need to continue working
|
||||
class KeyProvider
|
||||
def initialize(keys)
|
||||
@keys = Array(keys)
|
||||
end
|
||||
|
||||
# Returns the first key in the list as the active key to perform encryptions
|
||||
#
|
||||
# When +ActiveRecord::Encryption.config.store_key_references+ is true, the key will include
|
||||
# a public tag referencing the key itself. That key will be stored in the public
|
||||
# headers of the encrypted message
|
||||
def encryption_key
|
||||
@encryption_key ||= @keys.first.tap do |key|
|
||||
key.public_tags.encrypted_data_key_id = key.id if ActiveRecord::Encryption.config.store_key_references
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the list of decryption keys
|
||||
#
|
||||
# When the message holds a reference to its encryption key, it will return an array
|
||||
# with that key. If not, it will return the list of keys.
|
||||
def decryption_keys(encrypted_message)
|
||||
if encrypted_message.headers.encrypted_data_key_id
|
||||
keys_grouped_by_id[encrypted_message.headers.encrypted_data_key_id]
|
||||
else
|
||||
@keys
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def keys_grouped_by_id
|
||||
@keys_grouped_by_id ||= @keys.group_by(&:id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,98 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# Encrypts all the models belonging to the provided list of classes
|
||||
class MassEncryption
|
||||
attr_reader :classes, :last_class, :last_id, :progress_monitor, :skip_rich_texts
|
||||
|
||||
def initialize(progress_monitor: NullProgressMonitor.new, last_class: nil, last_id: nil, skip_rich_texts: false)
|
||||
@progress_monitor = progress_monitor
|
||||
@last_class = last_class
|
||||
@last_id = last_id
|
||||
@classes = []
|
||||
@skip_rich_texts = skip_rich_texts
|
||||
|
||||
raise ArgumentError, "When passing a :last_id you must pass a :last_class too" if last_id.present? && last_class.blank?
|
||||
end
|
||||
|
||||
def add(*classes)
|
||||
@classes.push(*classes)
|
||||
progress_monitor.total = calculate_total
|
||||
self
|
||||
end
|
||||
|
||||
def encrypt
|
||||
included_classes.each.with_index do |klass, index|
|
||||
ClassMassEncryption.new(klass, progress_monitor: progress_monitor, last_id: last_id, skip_rich_texts: skip_rich_texts).encrypt
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total
|
||||
total = sum_all(classes) - sum_all(excluded_classes)
|
||||
total -= last_class.where("id < ?", last_id) if last_id.present?
|
||||
total
|
||||
end
|
||||
|
||||
def sum_all(classes)
|
||||
classes.sum { |klass| klass.count }
|
||||
end
|
||||
|
||||
def included_classes
|
||||
classes - excluded_classes
|
||||
end
|
||||
|
||||
def excluded_classes
|
||||
if last_class
|
||||
last_class_index = classes.find_index(last_class)
|
||||
classes.find_all.with_index do |_, index|
|
||||
index >= last_class_index
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ClassMassEncryption
|
||||
attr_reader :klass, :progress_monitor, :last_id, :skip_rich_texts
|
||||
|
||||
def initialize(klass, progress_monitor: NullEncryptor.new, last_id: nil, skip_rich_texts: false)
|
||||
@klass = klass
|
||||
@progress_monitor = progress_monitor
|
||||
@last_id = last_id
|
||||
@skip_rich_texts = skip_rich_texts
|
||||
end
|
||||
|
||||
def encrypt
|
||||
klass.where("id >= ?", last_id.to_i).find_each.with_index do |record, index|
|
||||
encrypt_record(record)
|
||||
progress_monitor.increment
|
||||
progress_monitor.log("Encrypting #{klass.name.tableize} (last id = #{record.id})...") if index % 500 == 0
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def encrypt_record(record)
|
||||
record.encrypt(skip_rich_texts: skip_rich_texts)
|
||||
rescue
|
||||
logger.error("Error when encrypting #{record.class} record with id #{record.id}")
|
||||
raise
|
||||
end
|
||||
|
||||
def logger
|
||||
Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
class NullProgressMonitor
|
||||
def increment
|
||||
end
|
||||
|
||||
def total=(new_value) end
|
||||
|
||||
def log(text)
|
||||
puts text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A message defines the structure of the data we store in encrypted attributes. It contains:
|
||||
#
|
||||
# * An encrypted payload
|
||||
# * A list of unencrypted headers
|
||||
#
|
||||
# See +Encryptor#encrypt+
|
||||
class Message
|
||||
attr_accessor :payload, :headers
|
||||
|
||||
def initialize(payload: nil, headers: {})
|
||||
validate_payload_type(payload)
|
||||
|
||||
@payload = payload
|
||||
@headers = Properties.new(headers)
|
||||
end
|
||||
|
||||
def ==(other_message)
|
||||
payload == other_message.payload && headers == other_message.headers
|
||||
end
|
||||
|
||||
private
|
||||
def validate_payload_type(payload)
|
||||
unless payload.is_a?(String) || payload.nil?
|
||||
raise ActiveRecord::Encryption::Errors::ForbiddenClass, "Only string payloads allowed"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,78 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A message serializer that serializes +Messages+ with JSON.
|
||||
#
|
||||
# The generated structure is pretty simple:
|
||||
#
|
||||
# {
|
||||
# p: <payload>,
|
||||
# h: {
|
||||
# header1: value1,
|
||||
# header2: value2,
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Both the payload and the header values are encoded with Base64
|
||||
# to prevent JSON parsing errors and encoding issues when
|
||||
# storing the resulting serialized data.
|
||||
class MessageSerializer
|
||||
def load(serialized_content)
|
||||
data = JSON.parse(serialized_content)
|
||||
parse_message(data, 1)
|
||||
rescue JSON::ParserError
|
||||
raise ActiveRecord::Encryption::Errors::Encoding
|
||||
end
|
||||
|
||||
def dump(message)
|
||||
raise ActiveRecord::Encryption::Errors::ForbiddenClass unless message.is_a?(ActiveRecord::Encryption::Message)
|
||||
JSON.dump message_to_json(message)
|
||||
end
|
||||
|
||||
private
|
||||
def parse_message(data, level)
|
||||
raise ActiveRecord::Encryption::Errors::Decryption, "More than one level of hash nesting in headers is not supported" if level > 2
|
||||
ActiveRecord::Encryption::Message.new(payload: decode_if_needed(data["p"]), headers: parse_properties(data["h"], level))
|
||||
end
|
||||
|
||||
def parse_properties(headers, level)
|
||||
ActiveRecord::Encryption::Properties.new.tap do |properties|
|
||||
headers&.each do |key, value|
|
||||
properties[key] = value.is_a?(Hash) ? parse_message(value, level + 1) : decode_if_needed(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def message_to_json(message)
|
||||
{
|
||||
p: encode_if_needed(message.payload),
|
||||
h: headers_to_json(message.headers)
|
||||
}
|
||||
end
|
||||
|
||||
def headers_to_json(headers)
|
||||
headers.collect do |key, value|
|
||||
[key, value.is_a?(ActiveRecord::Encryption::Message) ? message_to_json(value) : encode_if_needed(value)]
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def encode_if_needed(value)
|
||||
if value.is_a?(String)
|
||||
::Base64.strict_encode64 value
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def decode_if_needed(value)
|
||||
if value.is_a?(String)
|
||||
::Base64.strict_decode64(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
raise Errors::Encoding
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# An encryptor that won't decrypt or encrypt. It will just return the passed
|
||||
# values
|
||||
class NullEncryptor
|
||||
def encrypt(clean_text, key_provider: nil, cipher_options: {})
|
||||
clean_text
|
||||
end
|
||||
|
||||
def decrypt(encrypted_text, key_provider: nil, cipher_options: {})
|
||||
encrypted_text
|
||||
end
|
||||
|
||||
def encrypted?(text)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,74 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# This is a wrapper for a hash of encryption properties. It is used by
|
||||
# +Key+ (public tags) and +Message+ (headers).
|
||||
#
|
||||
# Since properties are serialized in messages, it is important for storage
|
||||
# efficiency to keep their keys as short as possible. It defines accessors
|
||||
# for common properties that will keep these keys very short while exposing
|
||||
# a readable name.
|
||||
#
|
||||
# message.headers.encrypted_data_key # instead of message.headers[:k]
|
||||
#
|
||||
# See +Properties#DEFAULT_PROPERTIES+, +Key+, +Message+
|
||||
class Properties
|
||||
ALLOWED_VALUE_CLASSES = [String, ActiveRecord::Encryption::Message, Numeric, TrueClass, FalseClass, Symbol, NilClass]
|
||||
|
||||
delegate_missing_to :data
|
||||
delegate :==, to: :data
|
||||
|
||||
# For each entry it generates an accessor exposing the full name
|
||||
DEFAULT_PROPERTIES = {
|
||||
encrypted_data_key: "k",
|
||||
encrypted_data_key_id: "i",
|
||||
compressed: "c",
|
||||
iv: "iv",
|
||||
auth_tag: "at",
|
||||
encoding: "e"
|
||||
}
|
||||
|
||||
DEFAULT_PROPERTIES.each do |name, key|
|
||||
define_method name do
|
||||
self[key.to_sym]
|
||||
end
|
||||
|
||||
define_method "#{name}=" do |value|
|
||||
self[key.to_sym] = value
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(initial_properties = {})
|
||||
@data = {}
|
||||
add(initial_properties)
|
||||
end
|
||||
|
||||
# Set a value for a given key
|
||||
#
|
||||
# It will raise an +EncryptedContentIntegrity+ if the value exists
|
||||
def []=(key, value)
|
||||
raise Errors::EncryptedContentIntegrity, "Properties can't be overridden: #{key}" if key?(key)
|
||||
validate_value_type(value)
|
||||
data[key] = value
|
||||
end
|
||||
|
||||
def validate_value_type(value)
|
||||
unless ALLOWED_VALUE_CLASSES.find { |klass| value.is_a?(klass) }
|
||||
raise ActiveRecord::Encryption::Errors::ForbiddenClass, "Can't store a #{value.class}, only properties of type #{ALLOWED_VALUE_CLASSES.inspect} are allowed"
|
||||
end
|
||||
end
|
||||
|
||||
def add(other_properties)
|
||||
other_properties.each do |key, value|
|
||||
self[key.to_sym] = value
|
||||
end
|
||||
end
|
||||
|
||||
def to_h
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :data
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,22 @@
|
|||
module ActiveRecord
|
||||
module Encryption
|
||||
# A +NullEncryptor+ that will raise an error when trying to encrypt data
|
||||
#
|
||||
# This is useful when you want to reveal ciphertexts for debugging purposes
|
||||
# and you want to make sure you won't overwrite any encryptable attribute with
|
||||
# the wrong content.
|
||||
class ReadOnlyNullEncryptor
|
||||
def encrypt(clean_text, key_provider: nil, cipher_options: {})
|
||||
raise Errors::Encryption, "This encryptor is read-only"
|
||||
end
|
||||
|
||||
def decrypt(encrypted_text, key_provider: nil, cipher_options: {})
|
||||
encrypted_text
|
||||
end
|
||||
|
||||
def encrypted?(text)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -15,6 +15,7 @@ module ActiveRecord
|
|||
# = Active Record Railtie
|
||||
class Railtie < Rails::Railtie # :nodoc:
|
||||
config.active_record = ActiveSupport::OrderedOptions.new
|
||||
config.active_record.encryption = ActiveSupport::OrderedOptions.new
|
||||
|
||||
config.app_generators.orm :active_record, migration: true,
|
||||
timestamps: true
|
||||
|
@ -202,7 +203,7 @@ To keep using the current cache store, you can turn off cache versioning entirel
|
|||
configs = app.config.active_record
|
||||
|
||||
configs.each do |k, v|
|
||||
send "#{k}=", v
|
||||
send "#{k}=", v if k != :encryption
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -276,5 +277,34 @@ To keep using the current cache store, you can turn off cache versioning entirel
|
|||
self.signed_id_verifier_secret ||= -> { Rails.application.key_generator.generate_key("active_record/signed_id") }
|
||||
end
|
||||
end
|
||||
|
||||
initializer "active_record_encryption.configuration" do |app|
|
||||
config.before_initialize do
|
||||
ActiveRecord::Encryption.configure \
|
||||
master_key: app.credentials.dig(:active_record_encryption, :master_key) || ENV["ACTIVE_RECORD_ENCRYPTION_MASTER_KEY"],
|
||||
deterministic_key: app.credentials.dig(:active_record_encryption, :deterministic_key) || ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"],
|
||||
key_derivation_salt: app.credentials.dig(:active_record_encryption, :key_derivation_salt) || ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"],
|
||||
**config.active_record.encryption
|
||||
|
||||
# Encrypt active record fixtures
|
||||
if ActiveRecord::Encryption.config.encrypt_fixtures
|
||||
class ActiveRecord::Fixture
|
||||
prepend ActiveRecord::Encryption::EncryptedFixtures
|
||||
end
|
||||
end
|
||||
|
||||
# Support extended queries for deterministic attributes
|
||||
if ActiveRecord::Encryption.config.support_unencrypted_data
|
||||
ActiveRecord::Encryption::ExtendedDeterministicQueries.install_support
|
||||
end
|
||||
|
||||
# Filtered params
|
||||
ActiveSupport.on_load(:action_controller) do
|
||||
if ActiveRecord::Encryption.config.add_to_filter_parameters
|
||||
ActiveRecord::Encryption.install_auto_filtered_parameters(app)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -551,6 +551,20 @@ db_namespace = namespace :db do
|
|||
end
|
||||
end
|
||||
|
||||
namespace :encryption do
|
||||
desc "Generate a set of keys for configuring Active Record encryption in a given environment"
|
||||
task :generate_random_keys do
|
||||
puts <<~MSG
|
||||
Add this entry to the credentials of the target environment:
|
||||
|
||||
active_record_encryption:
|
||||
master_key: #{SecureRandom.alphanumeric(32)}
|
||||
deterministic_key: #{SecureRandom.alphanumeric(32)}
|
||||
key_derivation_salt: #{SecureRandom.alphanumeric(32)}
|
||||
MSG
|
||||
end
|
||||
end
|
||||
|
||||
namespace :test do
|
||||
# desc "Recreate the test database from the current schema"
|
||||
task load: %w(db:test:purge) do
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::Aes256GcmTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@key = ActiveRecord::Encryption.key_generator.generate_random_key length: ActiveRecord::Encryption::Cipher::Aes256Gcm.key_length
|
||||
@cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key)
|
||||
end
|
||||
|
||||
test "encrypts strings" do
|
||||
assert_cipher_encrypts(@cipher, "Some clear text")
|
||||
end
|
||||
|
||||
test "works with empty strings" do
|
||||
assert_cipher_encrypts(@cipher, "")
|
||||
end
|
||||
|
||||
test "uses non-deterministic encryption by default" do
|
||||
assert_not_equal @cipher.encrypt("Some text").payload, @cipher.encrypt("Some text").payload
|
||||
end
|
||||
|
||||
test "in deterministic mode, it generates the same ciphertext for the same inputs" do
|
||||
cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key, deterministic: true)
|
||||
|
||||
assert_cipher_encrypts(cipher, "Some clear text")
|
||||
|
||||
assert_equal cipher.encrypt("Some text").payload, cipher.encrypt("Some text").payload
|
||||
assert_not_equal cipher.encrypt("Some text").payload, cipher.encrypt("Some other text").payload
|
||||
end
|
||||
|
||||
test "it generates different ivs for different ciphertexts" do
|
||||
cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key, deterministic: true)
|
||||
|
||||
assert_equal cipher.encrypt("Some text").headers.iv, cipher.encrypt("Some text").headers.iv
|
||||
assert_not_equal cipher.encrypt("Some text").headers.iv, cipher.encrypt("Some other text").headers.iv
|
||||
end
|
||||
|
||||
private
|
||||
def assert_cipher_encrypts(cipher, content_to_encrypt)
|
||||
encrypted_content = cipher.encrypt(content_to_encrypt)
|
||||
assert_not_equal content_to_encrypt, encrypted_content
|
||||
assert_equal content_to_encrypt, cipher.decrypt(encrypted_content)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,63 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::CipherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@cipher = ActiveRecord::Encryption::Cipher.new
|
||||
@key = ActiveRecord::Encryption.key_generator.generate_random_key
|
||||
end
|
||||
|
||||
test "encrypts returns a encrypted test that can be decrypted with the same key" do
|
||||
encrypted_text = @cipher.encrypt("clean text", key: @key)
|
||||
assert_equal "clean text", @cipher.decrypt(encrypted_text, key: @key)
|
||||
end
|
||||
|
||||
test "by default, encrypts uses random initialization vectors for each encryption operation" do
|
||||
assert_not_equal @cipher.encrypt("clean text", key: @key), @cipher.encrypt("clean text", key: @key)
|
||||
end
|
||||
|
||||
test "deterministic encryption with :deterministic param" do
|
||||
assert_equal @cipher.encrypt("clean text", key: @key, deterministic: true).payload, @cipher.encrypt("clean text", key: @key, deterministic: true).payload
|
||||
end
|
||||
|
||||
test "raises an ArgumentError when provided a key with the wrong length" do
|
||||
assert_raises ArgumentError do
|
||||
@cipher.encrypt("clean text", key: "invalid key")
|
||||
end
|
||||
end
|
||||
|
||||
test "iv_length returns the iv length of the cipher" do
|
||||
assert_equal OpenSSL::Cipher.new("aes-256-gcm").iv_len, @cipher.iv_length
|
||||
end
|
||||
|
||||
test "generates different ciphertexts on different invocations with the same key (not deterministic)" do
|
||||
key = SecureRandom.bytes(32)
|
||||
assert_not_equal @cipher.encrypt("clean text", key: key), @cipher.encrypt("clean text", key: key)
|
||||
end
|
||||
|
||||
test "decrypt can work with multiple keys" do
|
||||
encrypted_text = @cipher.encrypt("clean text", key: @key)
|
||||
|
||||
assert_equal "clean text", @cipher.decrypt(encrypted_text, key: [ "some wrong key", @key ])
|
||||
assert_equal "clean text", @cipher.decrypt(encrypted_text, key: [ "some wrong key", @key, "some other wrong key" ])
|
||||
assert_equal "clean text", @cipher.decrypt(encrypted_text, key: [ @key, "some wrong key", "some other wrong key" ])
|
||||
end
|
||||
|
||||
test "decrypt will raise an ActiveRecord::Encryption::Errors::Decryption error when none of the keys works" do
|
||||
encrypted_text = @cipher.encrypt("clean text", key: @key)
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
@cipher.decrypt(encrypted_text, key: [ "some wrong key", "other wrong key" ])
|
||||
end
|
||||
end
|
||||
|
||||
test "keep encoding from the source string" do
|
||||
encrypted_text = @cipher.encrypt("some string".force_encoding(Encoding::ISO_8859_1), key: @key)
|
||||
decrypted_text = @cipher.decrypt(encrypted_text, key: @key)
|
||||
assert_equal Encoding::ISO_8859_1, decrypted_text.encoding
|
||||
end
|
||||
|
||||
test "can encode unicode strings with emojis" do
|
||||
encrypted_text = @cipher.encrypt("Getting around with the ⚡️Go Menu", key: @key)
|
||||
assert_equal "Getting around with the ⚡️Go Menu", @cipher.decrypt(encrypted_text, key: @key)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,26 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/post"
|
||||
|
||||
module ActiveRecord::Encryption
|
||||
class ConcurrencyTest < ActiveRecord::TestCase
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
end
|
||||
|
||||
test "models can be encrypted and decrypted in different threads concurrently" do
|
||||
4.times.collect { |index| thread_encrypting_and_decrypting("thread #{index}") }.each(&:join)
|
||||
end
|
||||
|
||||
def thread_encrypting_and_decrypting(thread_label)
|
||||
posts = 200.times.collect { |index| EncryptedPost.create! title: "Article #{index} (#{thread_label})", body: "Body #{index} (#{thread_label})" }
|
||||
|
||||
Thread.new do
|
||||
posts.each.with_index do |article, index|
|
||||
assert_encrypted_attribute article, :title, "Article #{index} (#{thread_label})"
|
||||
article.decrypt
|
||||
assert_not_encrypted_attribute article, :title, "Article #{index} (#{thread_label})"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
|
||||
class ActiveRecord::ConfigurableTest < ActiveRecord::TestCase
|
||||
test 'can access context properties with top level getters' do
|
||||
assert_equal ActiveRecord::Encryption.key_provider, ActiveRecord::Encryption.context.key_provider
|
||||
end
|
||||
|
||||
test "can add listeners that will get invoked when declaring encrypted attributes" do
|
||||
@klass, @attribute_name = nil
|
||||
ActiveRecord::Encryption.on_encrypted_attribute_declared do |declared_klass, declared_attribute_name|
|
||||
@klass = declared_klass
|
||||
@attribute_name = declared_attribute_name
|
||||
end
|
||||
|
||||
klass = Class.new(EncryptedBook) do
|
||||
self.table_name = "books"
|
||||
encrypt_attribute :isbn
|
||||
end
|
||||
|
||||
assert_equal klass, @klass
|
||||
assert_equal :isbn, @attribute_name
|
||||
end
|
||||
|
||||
test "install autofiltered params" do
|
||||
application = OpenStruct.new(config: OpenStruct.new(filter_parameters: []))
|
||||
ActiveRecord::Encryption.install_auto_filtered_parameters(application)
|
||||
|
||||
Class.new(EncryptedBook) do
|
||||
self.table_name = "books"
|
||||
encrypt_attribute :isbn
|
||||
end
|
||||
|
||||
assert_includes application.config.filter_parameters, :isbn
|
||||
end
|
||||
end
|
|
@ -0,0 +1,87 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
require "models/post"
|
||||
|
||||
class ActiveRecord::ContextsTest < ActiveRecord::TestCase
|
||||
fixtures :posts
|
||||
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
@post = EncryptedPost.create!(title: "Some encrypted post title", body: "Some body")
|
||||
@clean_title = @post.title
|
||||
end
|
||||
|
||||
test ".with_encryption_context lets you override properties" do
|
||||
ActiveRecord::Encryption.with_encryption_context(encryptor: ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
assert_protected_encrypted_attribute(@post, :title, @clean_title)
|
||||
@post.update!(title: "Some new title")
|
||||
end
|
||||
|
||||
assert_equal "Some new title", @post.title
|
||||
end
|
||||
|
||||
test ".with_encryption_context will restore previous context properties when there is an error" do
|
||||
ActiveRecord::Encryption.with_encryption_context(encryptor: ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
raise "Some error"
|
||||
end
|
||||
rescue
|
||||
assert_encrypted_attribute @post.reload, :title, @clean_title
|
||||
end
|
||||
|
||||
test ".with_encryption_context can be nested multiple times" do
|
||||
ActiveRecord::Encryption.with_encryption_context(encryptor: encryptor_1 = ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
assert_equal encryptor_1, ActiveRecord::Encryption.encryptor
|
||||
|
||||
ActiveRecord::Encryption.with_encryption_context(encryptor: encryptor_2 = ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
assert_equal encryptor_2, ActiveRecord::Encryption.encryptor
|
||||
|
||||
ActiveRecord::Encryption.with_encryption_context(encryptor: encryptor_3 = ActiveRecord::Encryption::NullEncryptor.new) do
|
||||
assert_equal encryptor_3, ActiveRecord::Encryption.encryptor
|
||||
end
|
||||
|
||||
assert_equal encryptor_2, ActiveRecord::Encryption.encryptor
|
||||
end
|
||||
|
||||
assert_equal encryptor_1, ActiveRecord::Encryption.encryptor
|
||||
end
|
||||
end
|
||||
|
||||
test ".without_encryption won't decrypt or encrypt data automatically" do
|
||||
ActiveRecord::Encryption.without_encryption do
|
||||
assert_protected_encrypted_attribute(@post, :title, @clean_title)
|
||||
|
||||
@post.update!(title: "Some new title")
|
||||
end
|
||||
|
||||
assert_equal "Some new title", @post.title
|
||||
end
|
||||
|
||||
test ".protecting_encrypted_data don't decrypt attributes automatically" do
|
||||
ActiveRecord::Encryption.protecting_encrypted_data do
|
||||
assert_protected_encrypted_attribute(@post, :title, @clean_title)
|
||||
end
|
||||
end
|
||||
|
||||
test ".protecting_encrypted_data allows db-queries on deterministic attributes" do
|
||||
book = EncryptedBook.create! name: "Dune"
|
||||
|
||||
ActiveRecord::Encryption.protecting_encrypted_data do
|
||||
assert_equal book, EncryptedBook.find_by(name: "Dune")
|
||||
end
|
||||
end
|
||||
|
||||
test ".protecting_encrypted_data will raise a validation error when modifying encrypting attributes" do
|
||||
ActiveRecord::Encryption.protecting_encrypted_data do
|
||||
assert_raises ActiveRecord::RecordInvalid do
|
||||
@post.update!(title: "Some new title")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def assert_protected_encrypted_attribute(model, attribute_name, clean_value)
|
||||
assert_equal model.reload.ciphertext_for(attribute_name), model.public_send(attribute_name)
|
||||
assert_not_equal clean_value, model.ciphertext_for(:title)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::DerivedSecretKeyProviderTest < ActiveRecord::TestCase
|
||||
setup do
|
||||
@message ||= ActiveRecord::Encryption::Message.new(payload: "some secret")
|
||||
@keys = build_keys(3)
|
||||
@key_provider = ActiveRecord::Encryption::KeyProvider.new(@keys)
|
||||
end
|
||||
|
||||
test "will derive a key with the right length from the given password" do
|
||||
key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new("some password")
|
||||
key = key_provider.encryption_key
|
||||
|
||||
assert_equal [ key ], key_provider.decryption_keys(ActiveRecord::Encryption::Message.new(payload: "some secret"))
|
||||
assert_equal ActiveRecord::Encryption.cipher.key_length, key.secret.bytesize
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is false" do
|
||||
ActiveRecord::Encryption.config.store_key_references = false
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is true" do
|
||||
ActiveRecord::Encryption.config.store_key_references = true
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
end
|
|
@ -0,0 +1,116 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/author"
|
||||
require "models/book"
|
||||
require "models/post"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptableRecordApiTest < ActiveRecord::TestCase
|
||||
fixtures :posts
|
||||
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
end
|
||||
|
||||
test "encrypt encrypts all the encryptable attributes" do
|
||||
title = "The Starfleet is here!"
|
||||
body = "<p>the Starfleet is here, we are safe now!</p>"
|
||||
|
||||
post = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedPost.create! title: title, body: body
|
||||
end
|
||||
|
||||
post.encrypt
|
||||
|
||||
assert_encrypted_attribute(post, :title, title)
|
||||
assert_encrypted_attribute(post, :body, body)
|
||||
end
|
||||
|
||||
test "encrypt won't fail for classes without attributes to encrypt" do
|
||||
posts(:welcome).encrypt
|
||||
end
|
||||
|
||||
test "decrypt decrypts encrypted attributes" do
|
||||
title = "the Starfleet is here!"
|
||||
body = "<p>the Starfleet is here, we are safe now!</p>"
|
||||
post = EncryptedPost.create! title: title, body: body
|
||||
assert_encrypted_attribute(post, :title, title)
|
||||
assert_encrypted_attribute(post, :body, body)
|
||||
|
||||
post.decrypt
|
||||
|
||||
assert_not_encrypted_attribute post.reload, :title, title
|
||||
assert_not_encrypted_attribute post, :body, body
|
||||
end
|
||||
|
||||
test "decrypt can be invoked multiple times" do
|
||||
post = EncryptedPost.create! title: "the Starfleet is here", body: "<p>the Starfleet is here, we are safe now!</p>"
|
||||
|
||||
3.times { post.decrypt }
|
||||
|
||||
assert_not_encrypted_attribute post.reload, :title, "the Starfleet is here"
|
||||
assert_not_encrypted_attribute post, :body, "<p>the Starfleet is here, we are safe now!</p>"
|
||||
end
|
||||
|
||||
test "encrypt can be invoked multiple times" do
|
||||
post = EncryptedPost.create! title: "the Starfleet is here", body: "<p>the Starfleet is here, we are safe now!</p>"
|
||||
|
||||
3.times { post.encrypt }
|
||||
|
||||
assert_encrypted_attribute post.reload, :title, "the Starfleet is here"
|
||||
assert_encrypted_attribute post, :body, "<p>the Starfleet is here, we are safe now!</p>"
|
||||
end
|
||||
|
||||
test "encrypted_attribute? returns false for regular attributes" do
|
||||
book = EncryptedBook.new(created_at: 1.day.ago)
|
||||
assert_not book.encrypted_attribute?(:created_at)
|
||||
end
|
||||
|
||||
test "encrypted_attribute? returns true for encrypted attributes which content is encrypted" do
|
||||
book = EncryptedBook.create!(name: "Dune")
|
||||
assert book.encrypted_attribute?(:name)
|
||||
end
|
||||
|
||||
test "encrypted_attribute? returns false for encrypted attributes which content is not encrypted" do
|
||||
book = ActiveRecord::Encryption.without_encryption { EncryptedBook.create!(name: "Dune") }
|
||||
assert_not book.encrypted_attribute?(:title)
|
||||
end
|
||||
|
||||
test "ciphertext_for returns the chiphertext for a given attributes" do
|
||||
book = EncryptedBook.create!(name: "Dune")
|
||||
|
||||
assert_equal book.ciphertext_for(:name), book.ciphertext_for(:name)
|
||||
assert_not_equal book.name, book.ciphertext_for(:name)
|
||||
end
|
||||
|
||||
test "encrypt won't change the encoding of strings even when compression is used" do
|
||||
title = "The Starfleet is here #{'OMG👌' * 50}!"
|
||||
encoding = title.encoding
|
||||
post = ActiveRecord::Encryption.without_encryption { EncryptedPost.create!(title: title, body: "some body") }
|
||||
post.encrypt
|
||||
assert_equal encoding, post.reload.title.encoding
|
||||
end
|
||||
|
||||
test "encrypt will preserve case when :ignore_case option is used" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
book = create_unencrypted_book_ignoring_case name: "Dune"
|
||||
|
||||
ActiveRecord::Encryption.without_encryption { assert_equal "Dune", book.reload.name }
|
||||
assert_equal "Dune", book.name
|
||||
|
||||
book.encrypt
|
||||
|
||||
assert_equal "Dune", book.name
|
||||
end
|
||||
|
||||
test "encrypt attributes encrypted with a previous encryption scheme" do
|
||||
author = EncryptedAuthor.create!(name: "david")
|
||||
old_type = EncryptedAuthor.type_for_attribute(:name).previous_types.first
|
||||
value_encrypted_with_old_type = old_type.serialize("dhh")
|
||||
ActiveRecord::Encryption.without_encryption do
|
||||
author.update!(name: value_encrypted_with_old_type)
|
||||
end
|
||||
|
||||
author.reload.encrypt
|
||||
assert_equal "dhh", author.reload.name
|
||||
end
|
||||
end
|
|
@ -0,0 +1,250 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/author"
|
||||
require "models/book"
|
||||
require "models/post"
|
||||
require "models/traffic_light"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::TestCase
|
||||
fixtures :books, :posts
|
||||
|
||||
test "encrypts the attribute seamlessly when creating and updating records" do
|
||||
post = EncryptedPost.create!(title: "The Starfleet is here!", body: "take cover!")
|
||||
assert_encrypted_attribute(post, :title, "The Starfleet is here!")
|
||||
|
||||
post.update!(title: "The Klingons are coming!")
|
||||
assert_encrypted_attribute(post, :title, "The Klingons are coming!")
|
||||
|
||||
post.title = "You sure?"
|
||||
post.save!
|
||||
assert_encrypted_attribute(post, :title, "You sure?")
|
||||
|
||||
post[:title] = "The Klingons are leaving!"
|
||||
post.save!
|
||||
assert_encrypted_attribute(post, :title, "The Klingons are leaving!")
|
||||
end
|
||||
|
||||
test "attribute is not accessible with the wrong key" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = false
|
||||
|
||||
post = EncryptedPost.create!(title: "The Starfleet is here!", body: "take cover!")
|
||||
post.reload.tags_count # accessing regular attributes works
|
||||
assert_invalid_key_cant_read_attribute(post, :title)
|
||||
end
|
||||
|
||||
test "ignores nil values" do
|
||||
assert_nil EncryptedBook.create!(name: nil).name
|
||||
end
|
||||
|
||||
test "ignores empty values" do
|
||||
assert_equal "", EncryptedBook.create!(name: "").name
|
||||
end
|
||||
|
||||
test "encrypts serialized attributes" do
|
||||
states = %i[ green red ]
|
||||
traffic_light = EncryptedTrafficLight.create!(state: states, long_state: states)
|
||||
assert_encrypted_attribute(traffic_light, :state, states)
|
||||
end
|
||||
|
||||
test "can configure a custom key provider on a per-record-class basis through the :key_provider option" do
|
||||
post = EncryptedPost.create!(title: "The Starfleet is here!", body: "take cover!")
|
||||
assert_encrypted_attribute(post, :body, "take cover!")
|
||||
end
|
||||
|
||||
test "can configure a custom key on a per-record-class basis through the :key option" do
|
||||
author = EncryptedAuthor.create!(name: "Stephen King")
|
||||
assert_encrypted_attribute(author, :name, "Stephen King")
|
||||
end
|
||||
|
||||
test "encrypts multiple attributes with different options at the same time" do
|
||||
post = EncryptedPost.create!\
|
||||
title: title = "The Starfleet is here!",
|
||||
body: body = "<p>the Starfleet is here, we are safe now!</p>"
|
||||
|
||||
assert_encrypted_attribute(post, :title, title)
|
||||
assert_encrypted_attribute(post, :body, body)
|
||||
end
|
||||
|
||||
test "encrypted_attributes returns the list of encrypted attributes in a model (each record class holds their own list)" do
|
||||
assert_equal Set.new([:title, :body]), EncryptedPost.encrypted_attributes
|
||||
assert_not_equal EncryptedAuthor.encrypted_attributes, EncryptedPost.encrypted_attributes
|
||||
end
|
||||
|
||||
test "deterministic_encrypted_attributes returns the list of deterministic encrypted attributes in a model (each record class holds their own list)" do
|
||||
assert_equal [:name], EncryptedBook.deterministic_encrypted_attributes
|
||||
assert_not_equal EncryptedPost.deterministic_encrypted_attributes, EncryptedBook.deterministic_encrypted_attributes
|
||||
end
|
||||
|
||||
test "by default, encryption is not deterministic" do
|
||||
post_1 = EncryptedPost.create!(title: "the same title", body: "some body")
|
||||
post_2 = EncryptedPost.create!(title: "the same title", body: "some body")
|
||||
|
||||
assert_not_equal post_1.ciphertext_for(:title), post_2.ciphertext_for(:title)
|
||||
end
|
||||
|
||||
test "deterministic attributes can be searched with Active Record queries" do
|
||||
EncryptedBook.create!(name: "Dune")
|
||||
|
||||
assert EncryptedBook.find_by(name: "Dune")
|
||||
assert_not EncryptedBook.find_by(name: "not Dune")
|
||||
|
||||
assert_equal 1, EncryptedBook.where(name: "Dune").count
|
||||
end
|
||||
|
||||
test "deterministic attributes can be created by passing deterministic: true" do
|
||||
book_1 = EncryptedBook.create!(name: "Dune")
|
||||
book_2 = EncryptedBook.create!(name: "Dune")
|
||||
|
||||
assert_equal book_1.ciphertext_for(:name), book_2.ciphertext_for(:name)
|
||||
end
|
||||
|
||||
test "encryption errors when saving records will raise the error and don't save anything" do
|
||||
assert_no_changes -> { BookThatWillFailToEncryptName.count } do
|
||||
assert_raises ActiveRecord::Encryption::Errors::Encryption do
|
||||
BookThatWillFailToEncryptName.create!(name: "Dune")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "can work with pre-encryption nil values" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
book = ActiveRecord::Encryption.without_encryption { EncryptedBook.create!(name: nil) }
|
||||
assert_nil book.name
|
||||
end
|
||||
|
||||
test "can work with pre-encryption empty values" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
book = ActiveRecord::Encryption.without_encryption { EncryptedBook.create!(name: "") }
|
||||
assert_equal "", book.name
|
||||
end
|
||||
|
||||
test "can't modify encrypted attributes when frozen_encryption is true" do
|
||||
post = posts(:welcome).becomes(EncryptedPost)
|
||||
post.title = "Some new title"
|
||||
assert post.valid?
|
||||
|
||||
ActiveRecord::Encryption.with_encryption_context frozen_encryption: true do
|
||||
assert_not post.valid?
|
||||
end
|
||||
end
|
||||
|
||||
test "can only save unencrypted attributes when frozen encryption is true" do
|
||||
book = books(:awdr).becomes(EncryptedBook)
|
||||
ActiveRecord::Encryption.with_encryption_context frozen_encryption: true do
|
||||
book.update! updated_at: Time.now
|
||||
end
|
||||
|
||||
ActiveRecord::Encryption.with_encryption_context frozen_encryption: true do
|
||||
assert_raises ActiveRecord::RecordInvalid do
|
||||
book.update! name: "Some new title"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "won't change the encoding of strings" do
|
||||
author_name = "Jorge"
|
||||
encoding = author_name.encoding
|
||||
author = EncryptedAuthor.create!(name: author_name)
|
||||
assert_equal encoding, author.reload.name.encoding
|
||||
end
|
||||
|
||||
test "by default, it's case sensitive" do
|
||||
EncryptedBook.create!(name: "Dune")
|
||||
assert EncryptedBook.find_by(name: "Dune")
|
||||
assert_not EncryptedBook.find_by(name: "dune")
|
||||
end
|
||||
|
||||
test "when using downcase: true it ignores case since everything will be downcase" do
|
||||
EncryptedBookWithDowncaseName.create!(name: "Dune")
|
||||
assert EncryptedBookWithDowncaseName.find_by(name: "Dune")
|
||||
assert EncryptedBookWithDowncaseName.find_by(name: "dune")
|
||||
assert EncryptedBookWithDowncaseName.find_by(name: "DUNE")
|
||||
end
|
||||
|
||||
test "when downcase: true it creates content downcased" do
|
||||
EncryptedBookWithDowncaseName.create!(name: "Dune")
|
||||
assert EncryptedBookWithDowncaseName.find_by_name("dune")
|
||||
end
|
||||
|
||||
test "when ignore_downcase: true, it ignores case in queries but keep it when reading the attribute" do
|
||||
EncryptedBookThatIgnoresCase.create!(name: "Dune")
|
||||
book = EncryptedBookThatIgnoresCase.find_by_name("dune")
|
||||
assert book
|
||||
assert "Dune", book.name
|
||||
end
|
||||
|
||||
test "when ignore_downcase: true, it keeps both the attribute and the _original counterpart encrypted" do
|
||||
book = EncryptedBookThatIgnoresCase.create!(name: "Dune")
|
||||
assert_encrypted_attribute book, :name, "Dune"
|
||||
assert_encrypted_attribute book, :original_name, "Dune"
|
||||
end
|
||||
|
||||
test "when ignore_downcase: true, it lets you update attributes normally" do
|
||||
book = EncryptedBookThatIgnoresCase.create!(name: "Dune")
|
||||
book.update!(name: "Dune II")
|
||||
assert_equal "Dune II", book.name
|
||||
end
|
||||
|
||||
test "when ignore_downcase: true, it returns the actual value when not encrypted" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
book = create_unencrypted_book_ignoring_case name: "Dune"
|
||||
assert_equal "Dune", book.name
|
||||
end
|
||||
|
||||
test "reading a not encrypted value will raise a Decryption error when :support_unencrypted_data is false" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = false
|
||||
|
||||
book = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedBookThatIgnoresCase.create!(name: "dune")
|
||||
end
|
||||
|
||||
assert_raises(ActiveRecord::Encryption::Errors::Decryption) do
|
||||
book.name
|
||||
end
|
||||
end
|
||||
|
||||
test "reading a not encrypted value won't raise a Decryption error when :support_unencrypted_data is true" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
author = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedAuthor.create!(name: "Stephen King")
|
||||
end
|
||||
|
||||
assert_equal "Stephen King", author.name
|
||||
end
|
||||
|
||||
if current_adapter?(:Mysql2Adapter)
|
||||
test "validate column sizes" do
|
||||
assert EncryptedAuthor.new(name: "jorge").valid?
|
||||
assert_not EncryptedAuthor.new(name: "a" * 256).valid?
|
||||
author = EncryptedAuthor.create(name: "a" * 256)
|
||||
assert_not author.valid?
|
||||
end
|
||||
end
|
||||
|
||||
test "track previous changes properly for encrypted attributes" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
book = EncryptedBook.create!(name: "Dune")
|
||||
book.update!(created_at: 1.hour.ago)
|
||||
assert_not book.name_previously_changed?
|
||||
|
||||
book.update!(name: "A new title!")
|
||||
assert book.name_previously_changed?
|
||||
end
|
||||
|
||||
private
|
||||
class FailingKeyProvider
|
||||
def decryption_key(message) end
|
||||
|
||||
def encryption_key
|
||||
raise ActiveRecord::Encryption::Errors::Encryption
|
||||
end
|
||||
end
|
||||
|
||||
class BookThatWillFailToEncryptName < Book
|
||||
self.table_name = "books"
|
||||
|
||||
encrypts :name, key_provider: FailingKeyProvider.new
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class EncryptedAttributeTypeTest < ActiveSupport::TestCase
|
||||
test "deterministic is true when some iv is set" do
|
||||
assert_not ActiveRecord::Encryption::EncryptedAttributeType.new.deterministic?
|
||||
|
||||
assert ActiveRecord::Encryption::EncryptedAttributeType.new(deterministic: true).deterministic?
|
||||
assert_not ActiveRecord::Encryption::EncryptedAttributeType.new(deterministic: false).deterministic?
|
||||
end
|
||||
end
|
|
@ -0,0 +1,18 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
|
||||
class EncryptableFixtureTest < ActiveRecord::TestCase
|
||||
fixtures :encrypted_books, :encrypted_book_that_ignores_cases
|
||||
|
||||
test "fixtures get encrypted automatically" do
|
||||
assert encrypted_books(:awdr).encrypted_attribute?(:name)
|
||||
end
|
||||
|
||||
test "preserved columns due to ignore_case: true gets encrypted automatically" do
|
||||
book = encrypted_book_that_ignores_cases(:rfr)
|
||||
assert_equal "Ruby for Rails", book.name
|
||||
assert_encrypted_attribute book, :name, "Ruby for Rails"
|
||||
|
||||
assert EncryptedBookThatIgnoresCase.find_by_name("Ruby for Rails")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,18 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptingOnlyEncryptorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@encryptor = ActiveRecord::Encryption::EncryptingOnlyEncryptor.new
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
end
|
||||
|
||||
test "decrypt returns the passed data" do
|
||||
assert_equal "Some data", @encryptor.decrypt("Some data")
|
||||
end
|
||||
|
||||
test "encrypt encrypts the passed data" do
|
||||
encrypted_text = @encryptor.encrypt("Some data")
|
||||
assert_not_equal encrypted_text, "Some data"
|
||||
assert_equal "Some data", ActiveRecord::Encryption::Encryptor.new.decrypt(encrypted_text)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,80 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/author"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptionSchemesTest < ActiveRecord::TestCase
|
||||
test "can decrypt encrypted_value encrypted with a different encryption scheme" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = false
|
||||
|
||||
author = create_author_with_name_encrypted_with_previous_scheme
|
||||
assert_equal "dhh", author.reload.name
|
||||
end
|
||||
|
||||
test "when defining previous encryption schemes, you still get Decryption errors when using invalid clear_value" do
|
||||
author = ActiveRecord::Encryption.without_encryption { EncryptedAuthor.create!(name: "unencrypted author") }
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
author.reload.name
|
||||
end
|
||||
end
|
||||
|
||||
test "use a custom encryptor" do
|
||||
author = EncryptedAuthor1.create name: "1"
|
||||
assert_equal "1", author.name
|
||||
end
|
||||
|
||||
test "support previous contexts" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
author = EncryptedAuthor2.create name: "2"
|
||||
assert_equal "2", author.name
|
||||
assert_equal author, EncryptedAuthor2.find_by_name("2")
|
||||
|
||||
Author.find(author.id).update! name: "1"
|
||||
assert_equal "1", author.reload.name
|
||||
assert_equal author, EncryptedAuthor2.find_by_name("1")
|
||||
end
|
||||
|
||||
private
|
||||
class TestEncryptor
|
||||
def initialize(ciphertexts_by_clear_value)
|
||||
@ciphertexts_by_clear_value = ciphertexts_by_clear_value
|
||||
end
|
||||
|
||||
def encrypt(clear_text, key_provider: nil, cipher_options: {})
|
||||
@ciphertexts_by_clear_value[clear_text] || clear_text
|
||||
end
|
||||
|
||||
def decrypt(encrypted_text, key_provider: nil, cipher_options: {})
|
||||
@ciphertexts_by_clear_value.each{ |clear_value, encrypted_value| return clear_value if encrypted_value == encrypted_text }
|
||||
raise ActiveRecord::Encryption::Errors::Decryption, "Couldn't find a match for #{encrypted_text} (#{@ciphertexts_by_clear_value.inspect})"
|
||||
end
|
||||
|
||||
def encrypted?(text)
|
||||
text == encrypted_text
|
||||
end
|
||||
end
|
||||
|
||||
class EncryptedAuthor1 < Author
|
||||
self.table_name = "authors"
|
||||
|
||||
encrypts :name, context: { encryptor: TestEncryptor.new( "1" => "2" ) }
|
||||
end
|
||||
|
||||
class EncryptedAuthor2 < Author
|
||||
self.table_name = "authors"
|
||||
|
||||
encrypts :name, context: { encryptor: TestEncryptor.new("2" => "3") }, previous: { context: { encryptor: TestEncryptor.new("1" => "2") } }
|
||||
end
|
||||
|
||||
def create_author_with_name_encrypted_with_previous_scheme
|
||||
author = EncryptedAuthor.create!(name: "david")
|
||||
old_type = EncryptedAuthor.type_for_attribute(:name).previous_types.first
|
||||
value_encrypted_with_old_type = old_type.serialize("dhh")
|
||||
ActiveRecord::Encryption.without_encryption do
|
||||
author.update!(name: value_encrypted_with_old_type)
|
||||
end
|
||||
author
|
||||
end
|
||||
|
||||
|
||||
end
|
|
@ -0,0 +1,83 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@secret_key = "This is my secret 256 bits key!!"
|
||||
@encryptor = ActiveRecord::Encryption::Encryptor.new
|
||||
end
|
||||
|
||||
test "encrypt and decrypt a string" do
|
||||
assert_encrypt_text("my secret text")
|
||||
end
|
||||
|
||||
test "decrypt and invalid string will raise a Decryption error" do
|
||||
assert_raises(ActiveRecord::Encryption::Errors::Decryption) do
|
||||
@encryptor.decrypt("some test that does not make sense")
|
||||
end
|
||||
end
|
||||
|
||||
test "decrypt an encrypted text with an invalid key will raise a Decryption error" do
|
||||
assert_raises(ActiveRecord::Encryption::Errors::Decryption) do
|
||||
encrypted_text = @encryptor.encrypt("Some text to encrypt")
|
||||
@encryptor.decrypt(encrypted_text, key_provider: ActiveRecord::Encryption::DerivedSecretKeyProvider.new("some invalid key"))
|
||||
end
|
||||
end
|
||||
|
||||
test "if an encryption error happens when encrypting an encrypted text it should raise" do
|
||||
assert_raises(ActiveRecord::Encryption::Errors::Encryption) do
|
||||
@encryptor.encrypt("Some text to encrypt", key_provider: key_provider_that_raises_an_encryption_error)
|
||||
end
|
||||
end
|
||||
|
||||
test "content is compressed" do
|
||||
content = SecureRandom.hex(5.kilobytes)
|
||||
cipher_text = @encryptor.encrypt(content)
|
||||
|
||||
assert_encrypt_text content
|
||||
assert cipher_text.bytesize < content.bytesize
|
||||
end
|
||||
|
||||
test "trying to encrypt custom classes raises a ForbiddenClass exception" do
|
||||
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
|
||||
@encryptor.encrypt(Struct.new(:name).new("Jorge"))
|
||||
end
|
||||
end
|
||||
|
||||
test "store custom metadata with the encrypted data, accessible by the key provider" do
|
||||
key = ActiveRecord::Encryption::Key.new(@secret_key)
|
||||
key.public_tags[:key] = "my tag"
|
||||
key_provider = ActiveRecord::Encryption::KeyProvider.new(key)
|
||||
encryptor = ActiveRecord::Encryption::Encryptor.new
|
||||
key_provider.expects(:decryption_keys).returns([key]).with do |message, params|
|
||||
message.headers[:key] == "my tag"
|
||||
end
|
||||
|
||||
encryptor.decrypt encryptor.encrypt("some text", key_provider: key_provider), key_provider: key_provider
|
||||
end
|
||||
|
||||
test "encrypted? returns whether the passed text is encrypted" do
|
||||
assert @encryptor.encrypted?(@encryptor.encrypt("clean text"))
|
||||
assert_not @encryptor.encrypted?("clean text")
|
||||
end
|
||||
|
||||
test "decrypt respects encoding even when compression is used" do
|
||||
text = "The Starfleet is here #{'OMG! ' * 50}!".force_encoding(Encoding::ISO_8859_1)
|
||||
encrypted_text = @encryptor.encrypt(text)
|
||||
decrypted_text = @encryptor.decrypt(encrypted_text)
|
||||
|
||||
assert_equal Encoding::ISO_8859_1, decrypted_text.encoding
|
||||
end
|
||||
|
||||
private
|
||||
def assert_encrypt_text(clean_text)
|
||||
encrypted_text = @encryptor.encrypt(clean_text)
|
||||
assert_not_equal encrypted_text, clean_text
|
||||
assert_equal clean_text, @encryptor.decrypt(encrypted_text)
|
||||
end
|
||||
|
||||
def key_provider_that_raises_an_encryption_error
|
||||
ActiveRecord::Encryption::DerivedSecretKeyProvider.new("some key").tap do |key_provider|
|
||||
key_provider.expects(:encryption_key).raises(ActiveRecord::Encryption::Errors::Encryption)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::EnvelopeEncryptionKeyProviderTest < ActiveRecord::TestCase
|
||||
setup do
|
||||
@key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new
|
||||
end
|
||||
|
||||
test "encryption_key returns random encryption keys" do
|
||||
keys = 5.times.collect { @key_provider.encryption_key }
|
||||
assert_equal 5, keys.group_by(&:secret).length
|
||||
end
|
||||
|
||||
test "generate_random_encryption_key generates keys of 32 bytes" do
|
||||
assert_equal 32, @key_provider.encryption_key.secret.bytesize
|
||||
end
|
||||
|
||||
test "generated random keys carry their secret encrypted with the master key" do
|
||||
key = @key_provider.encryption_key
|
||||
encrypted_secret = key.public_tags.encrypted_data_key
|
||||
assert_equal key.secret, ActiveRecord::Encryption.cipher.decrypt(encrypted_secret, key: @key_provider.active_master_key.secret)
|
||||
end
|
||||
|
||||
test "decryption_key_for returns the decryption key for a message that was encrypted with a generated encryption key" do
|
||||
key = @key_provider.encryption_key
|
||||
encrypted_encoded_message = ActiveRecord::Encryption.encryptor.encrypt("some message", key_provider: ActiveRecord::Encryption::KeyProvider.new(key))
|
||||
encrypted_message = ActiveRecord::Encryption.message_serializer.load encrypted_encoded_message
|
||||
assert_equal key.secret, @key_provider.decryption_keys(encrypted_message).first.secret
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is false" do
|
||||
ActiveRecord::Encryption.config.master_key = ["key 1", "key 2"]
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is true" do
|
||||
ActiveRecord::Encryption.config.master_key = ["key 1", "key 2"]
|
||||
ActiveRecord::Encryption.config.store_key_references = true
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
|
||||
private
|
||||
def assert_multiple_master_keys
|
||||
assert Rails.application.credentials.dig(:active_record_encryption, :master_key).length > 1
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
|
||||
class ExtendedDeterministicQueriesTest < ActiveRecord::TestCase
|
||||
test "Finds records when data is unencrypted" do
|
||||
ActiveRecord::Encryption.without_encryption { Book.create! name: "Dune" }
|
||||
assert EncryptedBook.find_by(name: "Dune") # core
|
||||
assert EncryptedBook.where("id > 0").find_by(name: "Dune") # relation
|
||||
end
|
||||
|
||||
test "Finds records when data is encrypted" do
|
||||
Book.create! name: "Dune"
|
||||
assert EncryptedBook.find_by(name: "Dune") # core
|
||||
assert EncryptedBook.where("id > 0").find_by(name: "Dune") # relation
|
||||
end
|
||||
|
||||
test "Works well with downcased attributes" do
|
||||
ActiveRecord::Encryption.without_encryption { EncryptedBookWithDowncaseName.create! name: "Dune" }
|
||||
assert EncryptedBookWithDowncaseName.find_by(name: "DUNE")
|
||||
end
|
||||
|
||||
test "find_or_create works" do
|
||||
EncryptedBook.find_or_create_by!(name: "Dune")
|
||||
assert EncryptedBook.find_by(name: "Dune")
|
||||
|
||||
EncryptedBook.find_or_create_by!(name: "Dune")
|
||||
assert EncryptedBook.find_by(name: "Dune")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,158 @@
|
|||
require "cases/helper"
|
||||
require "benchmark/ips"
|
||||
|
||||
ActiveRecord::Encryption.configure \
|
||||
master_key: "test master key",
|
||||
deterministic_key: "test deterministic key",
|
||||
key_derivation_salt: "testing key derivation salt"
|
||||
|
||||
ActiveRecord::Encryption::ExtendedDeterministicQueries.install_support
|
||||
|
||||
class ActiveRecord::Fixture
|
||||
prepend ActiveRecord::Encryption::EncryptedFixtures
|
||||
end
|
||||
|
||||
module EncryptionHelpers
|
||||
def assert_encrypted_attribute(model, attribute_name, expected_value)
|
||||
encrypted_content = model.ciphertext_for(attribute_name)
|
||||
assert_not_equal expected_value, encrypted_content
|
||||
assert_equal expected_value, model.public_send(attribute_name)
|
||||
assert_equal expected_value, model.reload.public_send(attribute_name) unless model.new_record?
|
||||
end
|
||||
|
||||
def assert_invalid_key_cant_read_attribute(model, attribute_name)
|
||||
if model.type_for_attribute(attribute_name).key_provider.present?
|
||||
assert_invalid_key_cant_read_attribute_with_custom_key_provider(model, attribute_name)
|
||||
else
|
||||
assert_invalid_key_cant_read_attribute_with_default_key_provider(model, attribute_name)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_not_encrypted_attribute(model, attribute_name, expected_value)
|
||||
assert_equal expected_value, model.send(attribute_name)
|
||||
assert_equal expected_value, model.ciphertext_for(attribute_name)
|
||||
end
|
||||
|
||||
def assert_encrypted_record(model)
|
||||
encrypted_attributes = model.class.encrypted_attributes.find_all { |attribute_name| model.send(attribute_name).present? }
|
||||
assert_not encrypted_attributes.empty?, "The model has no encrypted attributes with content to check (they are all blank)"
|
||||
|
||||
encrypted_attributes.each do |attribute_name|
|
||||
assert_encrypted_attribute model, attribute_name, model.send(attribute_name)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_encryptor_works_with(key_provider)
|
||||
encryptor = ActiveRecord::Encryption::Encryptor.new
|
||||
|
||||
encrypted_message = encryptor.encrypt("some text", key_provider: key_provider)
|
||||
assert_equal "some text", encryptor.decrypt(encrypted_message, key_provider: key_provider)
|
||||
end
|
||||
|
||||
private
|
||||
def build_keys(count = 3)
|
||||
count.times.collect do |index|
|
||||
password = "some secret #{index}"
|
||||
secret = ActiveRecord::Encryption.key_generator.derive_key_from(password)
|
||||
ActiveRecord::Encryption::Key.new(secret)
|
||||
end
|
||||
end
|
||||
|
||||
def with_key_provider(key_provider, &block)
|
||||
ActiveRecord::Encryption.with_encryption_context key_provider: key_provider, &block
|
||||
end
|
||||
|
||||
def with_envelope_encryption(&block)
|
||||
@envelope_encryption_key_provider ||= ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new
|
||||
with_key_provider @envelope_encryption_key_provider, &block
|
||||
end
|
||||
|
||||
def create_unencrypted_book_ignoring_case(name:)
|
||||
book = ActiveRecord::Encryption.without_encryption do
|
||||
EncryptedBookThatIgnoresCase.create!(name: name)
|
||||
end
|
||||
|
||||
# Skip type casting to simulate an upcase value. Not supported in AR without using private apis
|
||||
EncryptedBookThatIgnoresCase.connection.execute <<~SQL
|
||||
UPDATE books SET name = '#{name}' WHERE id = #{book.id};
|
||||
SQL
|
||||
|
||||
book.reload
|
||||
end
|
||||
|
||||
def assert_invalid_key_cant_read_attribute_with_default_key_provider(model, attribute_name)
|
||||
model.reload
|
||||
|
||||
ActiveRecord::Encryption.with_encryption_context key_provider: ActiveRecord::Encryption::DerivedSecretKeyProvider.new("a different 256 bits key for now") do
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
model.public_send(attribute_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def assert_invalid_key_cant_read_attribute_with_custom_key_provider(model, attribute_name)
|
||||
attribute_type = model.type_for_attribute(attribute_name)
|
||||
|
||||
model.reload
|
||||
|
||||
attribute_type.key_provider.key = ActiveRecord::Encryption::Key.derive_from "other custom attribute secret"
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
model.public_send(attribute_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module PerformanceHelpers
|
||||
BENCHMARK_DURATION = 1
|
||||
BENCHMARK_WARMUP = 1
|
||||
BASELINE_LABEL = "Baseline"
|
||||
CODE_TO_TEST_LABEL = "Code"
|
||||
|
||||
|
||||
# Usage:
|
||||
#
|
||||
# baseline = -> { <some baseline code> }
|
||||
#
|
||||
# assert_slower_by_at_most 2, baseline: baseline do
|
||||
# <the code you want to compare against the baseline>
|
||||
# end
|
||||
def assert_slower_by_at_most(threshold_factor, baseline:, baseline_label: BASELINE_LABEL, code_to_test_label: CODE_TO_TEST_LABEL, duration: BENCHMARK_DURATION, &block_to_test)
|
||||
GC.start
|
||||
|
||||
result = Benchmark.ips do |x|
|
||||
x.config(time: duration, warmup: BENCHMARK_WARMUP)
|
||||
x.report(code_to_test_label, &block_to_test)
|
||||
x.report(baseline_label, &baseline)
|
||||
x.compare!
|
||||
end
|
||||
|
||||
baseline_result = result.entries.find { |entry| entry.label == baseline_label }
|
||||
code_to_test_result = result.entries.find { |entry| entry.label == code_to_test_label }
|
||||
|
||||
times_slower = baseline_result.ips / code_to_test_result.ips
|
||||
|
||||
assert times_slower < threshold_factor, "Expecting #{threshold_factor} times slower at most, but got #{times_slower} times slower"
|
||||
end
|
||||
end
|
||||
|
||||
class ActiveRecord::TestCase
|
||||
include EncryptionHelpers, PerformanceHelpers
|
||||
#, PerformanceHelpers
|
||||
|
||||
ENCRYPTION_ERROR_FLAGS = %i[ master_key store_key_references key_derivation_salt support_unencrypted_data
|
||||
encrypt_fixtures ]
|
||||
|
||||
setup do
|
||||
ENCRYPTION_ERROR_FLAGS.each do |property|
|
||||
instance_variable_set "@_original_#{property}", ActiveRecord::Encryption.config.public_send(property)
|
||||
end
|
||||
ActiveRecord::Encryption.encrypted_attribute_declaration_listeners&.clear
|
||||
end
|
||||
|
||||
teardown do
|
||||
ENCRYPTION_ERROR_FLAGS.each do |property|
|
||||
ActiveRecord::Encryption.config.public_send("#{property}=", instance_variable_get("@_original_#{property}"))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,39 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::KeyGeneratorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@generator = ActiveRecord::Encryption::KeyGenerator.new
|
||||
end
|
||||
|
||||
test "generate_random_key generates random keys with the cipher key length by default" do
|
||||
assert_not_equal @generator.generate_random_key, @generator.generate_random_key
|
||||
assert_equal ActiveRecord::Encryption.cipher.key_length, @generator.generate_random_key.bytesize
|
||||
end
|
||||
|
||||
test "generate_random_key generates random keys with a custom length" do
|
||||
assert_not_equal @generator.generate_random_key(length: 10), @generator.generate_random_key(length: 10)
|
||||
assert_equal 10, @generator.generate_random_key(length: 10).bytesize
|
||||
end
|
||||
|
||||
test "generate_random_hex_key generates random hexadecimal keys with the cipher key length by default" do
|
||||
assert_not_equal @generator.generate_random_hex_key, @generator.generate_random_hex_key
|
||||
assert_equal ActiveRecord::Encryption.cipher.key_length, [ @generator.generate_random_hex_key ].pack("H*").bytesize
|
||||
end
|
||||
|
||||
test "generate_random_hex_key generates random hexadecimal keys with a custom length" do
|
||||
assert_not_equal @generator.generate_random_hex_key(length: 10), @generator.generate_random_hex_key(length: 10)
|
||||
assert_equal 10, [ @generator.generate_random_hex_key(length: 10) ].pack("H*").bytesize
|
||||
end
|
||||
|
||||
test "derive_key derives a key with from the provided password with the cipher key length by default" do
|
||||
assert_equal @generator.derive_key_from("some password"), @generator.derive_key_from("some password")
|
||||
assert_not_equal @generator.derive_key_from("some password"), @generator.derive_key_from("some other password")
|
||||
assert_equal ActiveRecord::Encryption.cipher.key_length, @generator.derive_key_from("some password").length
|
||||
end
|
||||
|
||||
test "derive_key derives a key with a custom length" do
|
||||
assert_equal @generator.derive_key_from("some password", length: 12), @generator.derive_key_from("some password", length: 12)
|
||||
assert_not_equal @generator.derive_key_from("some password", length: 12), @generator.derive_key_from("some other password", length: 12)
|
||||
assert_equal 12, @generator.derive_key_from("some password", length: 12).length
|
||||
end
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::KeyProviderTest < ActiveRecord::TestCase
|
||||
setup do
|
||||
@message ||= ActiveRecord::Encryption::Message.new(payload: "some secret")
|
||||
@keys = build_keys(3)
|
||||
@key_provider = ActiveRecord::Encryption::KeyProvider.new(@keys)
|
||||
end
|
||||
|
||||
test "serves a single key for encrypting and decrypting" do
|
||||
key = @keys.first
|
||||
key_provider = ActiveRecord::Encryption::KeyProvider.new(key)
|
||||
|
||||
assert_equal key, key_provider.encryption_key
|
||||
assert_equal [ key_provider.encryption_key ], key_provider.decryption_keys(@message)
|
||||
end
|
||||
|
||||
test "serves the first key for encrypting" do
|
||||
assert_equal @keys.first, @key_provider.encryption_key
|
||||
end
|
||||
|
||||
test "when store_key_references is false, the encryption key contains a reference to the key itself" do
|
||||
assert_nil @key_provider.encryption_key.public_tags.encrypted_data_key_id
|
||||
end
|
||||
|
||||
test "when store_key_references is true, the encryption key contains a reference to the key itself" do
|
||||
ActiveRecord::Encryption.config.store_key_references = true
|
||||
|
||||
assert_equal @keys.first.id, @key_provider.encryption_key.public_tags.encrypted_data_key_id
|
||||
end
|
||||
|
||||
test "when the message does not contain any key reference, it returns all the keys" do
|
||||
assert_equal @keys, @key_provider.decryption_keys(@message)
|
||||
end
|
||||
|
||||
test "when the message to decrypt contains a reference to the key id, it will return an array only with that message" do
|
||||
target_key = @keys[1]
|
||||
|
||||
@message.headers.encrypted_data_key_id = target_key.id
|
||||
|
||||
assert_equal [target_key], @key_provider.decryption_keys(@message)
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is false" do
|
||||
ActiveRecord::Encryption.config.store_key_references = false
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
|
||||
test "work with multiple keys when config.store_key_references is true" do
|
||||
ActiveRecord::Encryption.config.store_key_references = true
|
||||
|
||||
assert_encryptor_works_with @key_provider
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::KeyTest < ActiveSupport::TestCase
|
||||
test "A key can store a secret and public tags" do
|
||||
key = ActiveRecord::Encryption::Key.new("the secret")
|
||||
key.public_tags[:key] = "the key reference"
|
||||
|
||||
assert_equal "the secret", key.secret
|
||||
assert_equal "the key reference", key.public_tags[:key]
|
||||
end
|
||||
|
||||
test ".derive_from instantiates a key with its secret derived from the passed password" do
|
||||
assert_equal ActiveRecord::Encryption.key_generator.derive_key_from("some password"), ActiveRecord::Encryption::Key.derive_from("some password").secret
|
||||
end
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/author"
|
||||
require "models/post"
|
||||
|
||||
class ActiveRecord::Encryption::MassEncryptionTest < ActiveRecord::TestCase
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
end
|
||||
|
||||
test "It encrypts everything" do
|
||||
posts = ActiveRecord::Encryption.without_encryption do
|
||||
3.times.collect { |index| EncryptedPost.create!(title: "Article #{index}", body: "Body #{index}") }
|
||||
end
|
||||
|
||||
authors = ActiveRecord::Encryption.without_encryption do
|
||||
3.times.collect { |index| EncryptedAuthor.create!(name: "Author #{index}") }
|
||||
end
|
||||
|
||||
ActiveRecord::Encryption::MassEncryption.new\
|
||||
.add(EncryptedPost, EncryptedAuthor)
|
||||
.encrypt
|
||||
|
||||
(posts + authors).each { |model| assert_encrypted_record(model.reload) }
|
||||
end
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
require "test_helper"
|
||||
|
||||
class ActiveRecord::Encryption::MessageSerializerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@serializer = ActiveRecord::Encryption::MessageSerializer.new
|
||||
end
|
||||
|
||||
test "serializes messages" do
|
||||
message = build_message
|
||||
deserialized_message = serialize_and_deserialize(message)
|
||||
assert_equal message, deserialized_message
|
||||
end
|
||||
|
||||
test "serializes messages with nested messages in their headers" do
|
||||
message = build_message
|
||||
message.headers[:other_message] = ActiveRecord::Encryption::Message.new(payload: "some other secret payload", headers: { some_header: "some other value" })
|
||||
|
||||
deserialized_message = serialize_and_deserialize(message)
|
||||
assert_equal message, deserialized_message
|
||||
end
|
||||
|
||||
test "won't load classes from JSON" do
|
||||
class_loading_payload = '{"json_class": "MessageSerializerTest::SomeClassThatWillNeverExist"}'
|
||||
|
||||
assert_raises(ArgumentError) { JSON.load(class_loading_payload) }
|
||||
assert_nothing_raised { @serializer.load(class_loading_payload) }
|
||||
end
|
||||
|
||||
test "raises ForbiddenClass when trying to serialize other data types" do
|
||||
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
|
||||
@serializer.dump("it can only serialize messages!")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises Decryption when trying to parse message with more than one nested message" do
|
||||
message = build_message
|
||||
message.headers[:other_message] = ActiveRecord::Encryption::Message.new(payload: "some other secret payload", headers: { some_header: "some other value" })
|
||||
message.headers[:other_message].headers[:yet_another_message] = ActiveRecord::Encryption::Message.new(payload: "yet some other secret payload", headers: { some_header: "yet some other value" })
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
serialize_and_deserialize(message)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_message
|
||||
payload = "some payload"
|
||||
headers = { key_1: "1" }
|
||||
ActiveRecord::Encryption::Message.new(payload: payload, headers: headers)
|
||||
end
|
||||
|
||||
def serialize_and_deserialize(message, with: @serializer)
|
||||
@serializer.load @serializer.dump(message)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,41 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::MessageTest < ActiveSupport::TestCase
|
||||
test "add_header lets you add headers" do
|
||||
message = ActiveRecord::Encryption::Message.new
|
||||
message.headers[:header_1] = "value 1"
|
||||
|
||||
assert_equal "value 1", message.headers[:header_1]
|
||||
end
|
||||
|
||||
test "add_headers lets you add multiple headers" do
|
||||
message = ActiveRecord::Encryption::Message.new
|
||||
message.headers.add(header_1: "value 1", header_2: "value 2")
|
||||
assert_equal "value 1", message.headers[:header_1]
|
||||
assert_equal "value 2", message.headers[:header_2]
|
||||
end
|
||||
|
||||
test "headers can't be overridden" do
|
||||
message = ActiveRecord::Encryption::Message.new
|
||||
message.headers.add(header_1: "value 1")
|
||||
|
||||
assert_raises(ActiveRecord::Encryption::Errors::EncryptedContentIntegrity) do
|
||||
message.headers.add(header_1: "value 1")
|
||||
end
|
||||
|
||||
assert_raises(ActiveRecord::Encryption::Errors::EncryptedContentIntegrity) do
|
||||
message.headers.add(header_1: "value 1")
|
||||
end
|
||||
end
|
||||
|
||||
test "validates that payloads are either nil or strings" do
|
||||
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
|
||||
ActiveRecord::Encryption::Message.new(payload: Date.new)
|
||||
ActiveRecord::Encryption::Message.new(payload: [])
|
||||
end
|
||||
|
||||
ActiveRecord::Encryption::Message.new
|
||||
ActiveRecord::Encryption::Message.new(payload: "")
|
||||
ActiveRecord::Encryption::Message.new(payload: "Some payload")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::NullEncryptorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@encryptor = ActiveRecord::Encryption::NullEncryptor.new
|
||||
end
|
||||
|
||||
test "encrypt returns the passed data" do
|
||||
assert_equal "Some data", @encryptor.encrypt("Some data")
|
||||
end
|
||||
|
||||
test "decrypt returns the passed data" do
|
||||
assert_equal "Some data", @encryptor.decrypt("Some data")
|
||||
end
|
||||
|
||||
test "encrypted? returns false" do
|
||||
assert_not @encryptor.encrypted?("Some data")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
require "models/post"
|
||||
|
||||
class ActiveRecord::Encryption::EncryptionPerformanceTest < ActiveRecord::TestCase
|
||||
fixtures :encrypted_books, :posts
|
||||
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
end
|
||||
|
||||
test "performance when saving records" do
|
||||
baseline = -> { create_post_without_encryption }
|
||||
|
||||
assert_slower_by_at_most 1.6, baseline: baseline do
|
||||
create_post_with_encryption
|
||||
end
|
||||
end
|
||||
|
||||
test "reading an encrypted attribute multiple times is as fast as reading a regular attribute" do
|
||||
unencrypted_post = create_post_without_encryption
|
||||
baseline = -> { unencrypted_post.reload.title }
|
||||
|
||||
encrypted_post = create_post_with_encryption
|
||||
assert_slower_by_at_most 1, baseline: baseline, duration: 3 do
|
||||
encrypted_post.reload.title
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def create_post_without_encryption
|
||||
Post.create!\
|
||||
title: "the Starfleet is here!",
|
||||
body: "<p>the Starfleet is here, we are safe now!</p>"
|
||||
end
|
||||
|
||||
def create_post_with_encryption
|
||||
EncryptedPost.create!\
|
||||
title: "the Starfleet is here!",
|
||||
body: "<p>the Starfleet is here, we are safe now!</p>"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,51 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
|
||||
class ActiveRecord::Encryption::EvenlopeEncryptionPerformanceTest < ActiveRecord::TestCase
|
||||
fixtures :encrypted_books
|
||||
|
||||
setup do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
@envelope_encryption_key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new
|
||||
end
|
||||
|
||||
test "performance when saving records" do
|
||||
baseline = -> { create_book_without_encryption }
|
||||
|
||||
assert_slower_by_at_most 1.8, baseline: baseline do
|
||||
with_envelope_encryption do
|
||||
create_book
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "reading an encrypted attribute multiple times is as fast as reading a regular attribute" do
|
||||
with_envelope_encryption do
|
||||
baseline = -> { encrypted_books(:awdr).created_at }
|
||||
book = create_book
|
||||
assert_slower_by_at_most 1.05, baseline: baseline, duration: 3 do
|
||||
book.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def create_book_without_encryption
|
||||
ActiveRecord::Encryption.without_encryption { create_book }
|
||||
end
|
||||
|
||||
def create_book
|
||||
EncryptedBook.create! name: "Dune"
|
||||
end
|
||||
|
||||
def encrypt_unencrypted_book
|
||||
book = create_book_without_encryption
|
||||
with_envelope_encryption do
|
||||
book.encrypt
|
||||
end
|
||||
end
|
||||
|
||||
def with_envelope_encryption(&block)
|
||||
with_key_provider @envelope_encryption_key_provider, &block
|
||||
end
|
||||
end
|
|
@ -0,0 +1,21 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/book"
|
||||
|
||||
class ExtendedDeterministicQueriesPerformanceTest < ActiveRecord::TestCase
|
||||
# TODO: Is this failing only with SQLite/in memory adapter?
|
||||
test "finding with prepared statement caching by deterministically encrypted columns" do
|
||||
baseline = -> { EncryptedBook.find_by(format: "paperback") } # not encrypted
|
||||
|
||||
assert_slower_by_at_most 1.7, baseline: baseline, duration: 2 do
|
||||
EncryptedBook.find_by(name: "Agile Web Development with Rails") # encrypted, deterministic
|
||||
end
|
||||
end
|
||||
|
||||
test "finding without prepared statement caching by encrypted columns (deterministic)" do
|
||||
baseline = -> { EncryptedBook.where("id > 0").find_by(format: "paperback") } # not encrypted
|
||||
|
||||
assert_slower_by_at_most 1.7, baseline: baseline, duration: 2 do
|
||||
EncryptedBook.where("id > 0").find_by(name: "Agile Web Development with Rails") # encrypted, deterministic
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,65 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::StoragePerformanceTest < ActiveRecord::TestCase
|
||||
test "storage overload without storing keys is acceptable" do
|
||||
assert_storage_performance size: 2, overload_less_than: 43
|
||||
assert_storage_performance size: 50, overload_less_than: 4
|
||||
assert_storage_performance size: 255, overload_less_than: 1.6
|
||||
assert_storage_performance size: 1.kilobyte, overload_less_than: 1.15
|
||||
|
||||
[500.kilobytes, 1.megabyte, 10.megabyte].each do |size|
|
||||
assert_storage_performance size: size, overload_less_than: 1.03
|
||||
end
|
||||
end
|
||||
|
||||
test "storage overload storing keys is acceptable for DerivedSecretKeyProvider" do
|
||||
ActiveRecordEncryption.config.store_key_references = true
|
||||
|
||||
ActiveRecordEncryption.with_encryption_context key_provider: ActiveRecordEncryption::DerivedSecretKeyProvider.new("some secret") do
|
||||
assert_storage_performance size: 2, overload_less_than: 51
|
||||
assert_storage_performance size: 50, overload_less_than: 3.3
|
||||
assert_storage_performance size: 255, overload_less_than: 1.63
|
||||
assert_storage_performance size: 1.kilobyte, overload_less_than: 1.16
|
||||
|
||||
[500.kilobytes, 1.megabyte, 10.megabyte].each do |size|
|
||||
assert_storage_performance size: size, overload_less_than: 1.03
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "storage overload storing keys is acceptable for EnvelopeEncryptionKeyProvider" do
|
||||
ActiveRecordEncryption.config.store_key_references = true
|
||||
|
||||
with_envelope_encryption do
|
||||
assert_storage_performance size: 2, overload_less_than: 113
|
||||
assert_storage_performance size: 50, overload_less_than: 5.8
|
||||
assert_storage_performance size: 255, overload_less_than: 2.11
|
||||
assert_storage_performance size: 1.kilobyte, overload_less_than: 1.28
|
||||
|
||||
[500.kilobytes, 1.megabyte, 10.megabyte].each do |size|
|
||||
assert_storage_performance size: size, overload_less_than: 1.015
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def assert_storage_performance(size:, overload_less_than:)
|
||||
clear_content = SecureRandom.urlsafe_base64(size).first(size) # .alphanumeric is very slow for large sizes
|
||||
encrypted_content = encryptor.encrypt(clear_content)
|
||||
|
||||
puts "#{clear_content.bytesize}; #{encrypted_content.bytesize}; #{(encrypted_content.bytesize / clear_content.bytesize.to_f)}"
|
||||
|
||||
overload_factor = encrypted_content.bytesize.to_f / clear_content.bytesize
|
||||
assert\
|
||||
overload_factor <= overload_less_than,
|
||||
"Expecting an storage overload of #{overload_less_than} at most for #{size} bytes, but got #{overload_factor} instead"
|
||||
end
|
||||
|
||||
def encryptor
|
||||
@encryptor ||= ActiveRecordEncryption::Encryptor.new
|
||||
end
|
||||
|
||||
def cipher
|
||||
@cipher ||= ActiveRecordEncryption::Cipher.new
|
||||
end
|
||||
end
|
|
@ -0,0 +1,66 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
module ActiveRecord::Encryption
|
||||
class PropertiesTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@properties = ActiveRecord::Encryption::Properties.new
|
||||
end
|
||||
|
||||
test "behaves like a hash" do
|
||||
@properties[:key_1] = "value 1"
|
||||
@properties[:key_2] = "value 2"
|
||||
|
||||
assert_equal "value 1", @properties[:key_1]
|
||||
assert_equal "value 2", @properties[:key_2]
|
||||
end
|
||||
|
||||
test "defines custom accessors for some default properties" do
|
||||
auth_tag = "some auth tag"
|
||||
|
||||
@properties.auth_tag = auth_tag
|
||||
assert_equal auth_tag, @properties.auth_tag
|
||||
assert_equal auth_tag, @properties[:at]
|
||||
end
|
||||
|
||||
test "raises EncryptedContentIntegrity when trying to override properties" do
|
||||
@properties[:key_1] = "value 1"
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::EncryptedContentIntegrity do
|
||||
@properties[:key_1] = "value 1"
|
||||
end
|
||||
end
|
||||
|
||||
test "add will add all the properties passed" do
|
||||
@properties.add(key_1: "value 1", key_2: "value 2")
|
||||
|
||||
assert_equal "value 1", @properties[:key_1]
|
||||
assert_equal "value 2", @properties[:key_2]
|
||||
end
|
||||
|
||||
test "validate allowed types on creation" do
|
||||
example_of_valid_values.each do |value|
|
||||
ActiveRecord::Encryption::Properties.new(some_value: value)
|
||||
end
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
|
||||
ActiveRecord::Encryption::Properties.new(my_class: MyClass.new)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate allowed_types setting headers" do
|
||||
example_of_valid_values.each.with_index do |value, index|
|
||||
@properties["some_value_#{index}"] = value
|
||||
end
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
|
||||
@properties["some_value"] = MyClass.new
|
||||
end
|
||||
end
|
||||
|
||||
MyClass = Struct.new(:some_value)
|
||||
|
||||
def example_of_valid_values
|
||||
[ "a string", 123, 123.5, true, false, nil, :a_symbol, ActiveRecord::Encryption::Message.new ]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,17 @@
|
|||
require "cases/encryption/helper"
|
||||
|
||||
class ActiveRecord::Encryption::ReadOnlyNullEncryptorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@encryptor = ActiveRecord::Encryption::ReadOnlyNullEncryptor.new
|
||||
end
|
||||
|
||||
test "decrypt returns the encrypted message" do
|
||||
assert "some text", @encryptor.decrypt("some text")
|
||||
end
|
||||
|
||||
test "encrypt raises an Encryption" do
|
||||
assert_raises ActiveRecord::Encryption::Errors::Encryption do
|
||||
@encryptor.encrypt("some text")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
require "cases/encryption/helper"
|
||||
require "models/post"
|
||||
|
||||
class ActiveRecord::Encryption::UnencryptedAttributesTest < ActiveRecord::TestCase
|
||||
test "when :support_unencrypted_data is off, it works with unencrypted attributes normally" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = true
|
||||
|
||||
post = ActiveRecord::Encryption.without_encryption { EncryptedPost.create!(title: "The Starfleet is here!", body: "take cover!") }
|
||||
assert_not_encrypted_attribute(post, :title, "The Starfleet is here!")
|
||||
|
||||
# It will encrypt on saving
|
||||
post.update! title: "Other title"
|
||||
assert_encrypted_attribute(post.reload, :title, "Other title")
|
||||
end
|
||||
|
||||
test "when :support_unencrypted_data is on, it won't work with unencrypted attributes" do
|
||||
ActiveRecord::Encryption.config.support_unencrypted_data = false
|
||||
|
||||
post = ActiveRecord::Encryption.without_encryption { EncryptedPost.create!(title: "The Starfleet is here!", body: "take cover!") }
|
||||
|
||||
assert_raises ActiveRecord::Encryption::Errors::Decryption do
|
||||
post.title
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
rfr:
|
||||
author_id: 1
|
||||
id: 2
|
||||
name: "Ruby for Rails"
|
||||
format: "ebook"
|
||||
status: "proposed"
|
||||
last_read: "reading"
|
|
@ -0,0 +1,13 @@
|
|||
awdr:
|
||||
author_id: 1
|
||||
id: 1
|
||||
name: "Agile Web Development with Rails"
|
||||
format: "paperback"
|
||||
status: :published
|
||||
last_read: :read
|
||||
language: :english
|
||||
author_visibility: :visible
|
||||
illustrator_visibility: :visible
|
||||
font_size: :medium
|
||||
difficulty: :medium
|
||||
boolean_status: :enabled
|
|
@ -258,3 +258,9 @@ class AuthorFavoriteWithScope < ActiveRecord::Base
|
|||
belongs_to :author
|
||||
belongs_to :favorite_author, class_name: "Author"
|
||||
end
|
||||
|
||||
class EncryptedAuthor < Author
|
||||
self.table_name = "authors"
|
||||
|
||||
encrypts :name, key: "my very own key", previous: { deterministic: true }
|
||||
end
|
||||
|
|
|
@ -33,3 +33,21 @@ class PublishedBook < ActiveRecord::Base
|
|||
|
||||
validates_uniqueness_of :isbn
|
||||
end
|
||||
|
||||
class EncryptedBook < Book
|
||||
self.table_name = "books"
|
||||
|
||||
encrypts :name, deterministic: true
|
||||
end
|
||||
|
||||
class EncryptedBookWithDowncaseName < Book
|
||||
self.table_name = "books"
|
||||
|
||||
encrypts :name, deterministic: true, downcase: true
|
||||
end
|
||||
|
||||
class EncryptedBookThatIgnoresCase < Book
|
||||
self.table_name = "books"
|
||||
|
||||
encrypts :name, deterministic: true, ignore_case: true
|
||||
end
|
||||
|
|
|
@ -377,3 +377,15 @@ class Postesque < ActiveRecord::Base
|
|||
belongs_to :author_with_address, class_name: "Author", foreign_key: :author_id
|
||||
belongs_to :author_with_the_letter_a, class_name: "Author", foreign_key: :author_id
|
||||
end
|
||||
|
||||
class EncryptedPost < Post
|
||||
self.table_name = "posts"
|
||||
|
||||
# We want to modify the key for testing purposes
|
||||
class MutableDerivedSecretKeyProvider < ActiveRecord::Encryption::DerivedSecretKeyProvider
|
||||
attr_accessor :key
|
||||
end
|
||||
|
||||
encrypts :title
|
||||
encrypts :body, key_provider: MutableDerivedSecretKeyProvider.new("my post body secret!")
|
||||
end
|
|
@ -4,3 +4,7 @@ class TrafficLight < ActiveRecord::Base
|
|||
serialize :state, Array
|
||||
serialize :long_state, Array
|
||||
end
|
||||
|
||||
class EncryptedTrafficLight < TrafficLight
|
||||
encrypts :state
|
||||
end
|
|
@ -111,6 +111,7 @@ ActiveRecord::Schema.define do
|
|||
t.column :cover, :string, default: "hard"
|
||||
t.string :isbn
|
||||
t.string :external_id
|
||||
t.column :original_name, :string
|
||||
t.datetime :published_on
|
||||
t.boolean :boolean_status
|
||||
t.index [:author_id, :name], unique: true
|
||||
|
|
Loading…
Reference in New Issue