Initial extraction from active_record_encryption gem

This commit is contained in:
Jorge Manrubia 2021-02-23 19:02:10 +01:00
parent c6ee8c5c4f
commit 638a92f734
71 changed files with 3334 additions and 3 deletions

@ -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 Attribute
#
# 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 @@ def #{name}=(body)
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 @@ def create_file_blob(filename:, content_type:, metadata: nil)
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 @@ class Base
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 @@ class Railtie < Rails::Railtie # :nodoc:
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 @@ class Railtie < Rails::Railtie # :nodoc:
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 @@
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