diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 916b25258ee0c..5c1af92c74051 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,19 @@ +* Support decrypting data encrypted non-deterministically with a SHA1 hash digest. + + This adds a new Active Record encryption option to support decrypting data encrypted + non-deterministically with a SHA1 hash digest: + + ``` + Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true + ``` + + The new option addresses a problem when upgrading from 7.0 to 7.1. Due to a bug in how Active Record + Encryption was getting initialized, the key provider used for non-deterministic encryption were using + SHA-1 as its digest class, instead of the one configured globally by Rails via + `Rails.application.config.active_support.key_generator_hash_digest_class`. + + *Cadu Ribeiro and Jorge Manrubia* + * Apply scope to association subqueries. (belongs_to/has_one/has_many) Given: `has_many :welcome_posts, -> { where(title: "welcome") }` diff --git a/activerecord/lib/active_record/encryption.rb b/activerecord/lib/active_record/encryption.rb index a8404918fa0c0..73ec3087767f4 100644 --- a/activerecord/lib/active_record/encryption.rb +++ b/activerecord/lib/active_record/encryption.rb @@ -8,6 +8,7 @@ module Encryption extend ActiveSupport::Autoload eager_autoload do + autoload :AutoFilteredParameters autoload :Cipher autoload :Config autoload :Configurable diff --git a/activerecord/lib/active_record/encryption/auto_filtered_parameters.rb b/activerecord/lib/active_record/encryption/auto_filtered_parameters.rb new file mode 100644 index 0000000000000..313c8f3ef68d3 --- /dev/null +++ b/activerecord/lib/active_record/encryption/auto_filtered_parameters.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ActiveRecord + module Encryption + class AutoFilteredParameters + def initialize(app) + @app = app + @attributes_by_class = Concurrent::Map.new + @collecting = true + + install_collecting_hook + end + + def enable + apply_collected_attributes + @collecting = false + end + + private + attr_reader :app + + def install_collecting_hook + ActiveRecord::Encryption.on_encrypted_attribute_declared do |klass, attribute| + attribute_was_declared(klass, attribute) + end + end + + def attribute_was_declared(klass, attribute) + if collecting? + collect_for_later(klass, attribute) + else + apply_filter(klass, attribute) + end + end + + def apply_collected_attributes + @attributes_by_class.each do |klass, attributes| + attributes.each do |attribute| + apply_filter(klass, attribute) + end + end + end + + def collecting? + @collecting + end + + def collect_for_later(klass, attribute) + @attributes_by_class[klass] ||= Concurrent::Array.new + @attributes_by_class[klass] << attribute + end + + def apply_filter(klass, attribute) + filter = [("#{klass.model_name.element}" if klass.name), attribute.to_s].compact.join(".") + unless excluded_from_filter_parameters?(filter) + app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter) + klass.filter_attributes += [ attribute ] + end + end + + def excluded_from_filter_parameters?(filter_parameter) + ActiveRecord::Encryption.config.excluded_from_filter_parameters.find { |excluded_filter| excluded_filter.to_s == filter_parameter } + end + end + end +end diff --git a/activerecord/lib/active_record/encryption/config.rb b/activerecord/lib/active_record/encryption/config.rb index 5e5cf3a842495..368bbffb82628 100644 --- a/activerecord/lib/active_record/encryption/config.rb +++ b/activerecord/lib/active_record/encryption/config.rb @@ -23,10 +23,23 @@ def previous=(previous_schemes_properties) end end + def support_sha1_for_non_deterministic_encryption=(value) + if value && has_primary_key? + sha1_key_generator = ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: OpenSSL::Digest::SHA1) + sha1_key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(primary_key, key_generator: sha1_key_generator) + add_previous_scheme key_provider: sha1_key_provider + end + end + %w(key_derivation_salt primary_key deterministic_key).each do |key| + silence_redefinition_of_method "has_#{key}?" + define_method("has_#{key}?") do + instance_variable_get(:"@#{key}").presence + end + silence_redefinition_of_method key define_method(key) do - instance_variable_get(:"@#{key}").presence or + public_send("has_#{key}?") or raise Errors::Configuration, "Missing Active Record encryption credential: active_record_encryption.#{key}" end end diff --git a/activerecord/lib/active_record/encryption/configurable.rb b/activerecord/lib/active_record/encryption/configurable.rb index dec4f0fdb3490..472b12490db0d 100644 --- a/activerecord/lib/active_record/encryption/configurable.rb +++ b/activerecord/lib/active_record/encryption/configurable.rb @@ -22,11 +22,18 @@ def configure(primary_key: nil, deterministic_key: nil, key_derivation_salt: nil config.deterministic_key = deterministic_key config.key_derivation_salt = key_derivation_salt + # Set the default for this property here instead of in +Config#set_defaults+ as this needs + # to happen *after* the keys have been set. + properties[:support_sha1_for_non_deterministic_encryption] = true if properties[:support_sha1_for_non_deterministic_encryption].nil? + + properties.each do |name, value| + ActiveRecord::Encryption.config.send "#{name}=", value if ActiveRecord::Encryption.config.respond_to?("#{name}=") + end + + ActiveRecord::Encryption.reset_default_context + 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 + ActiveRecord::Encryption.context.send "#{name}=", value if ActiveRecord::Encryption.context.respond_to?("#{name}=") end end @@ -47,21 +54,6 @@ def encrypted_attribute_was_declared(klass, name) # :nodoc: block.call(klass, name) end end - - def install_auto_filtered_parameters_hook(app) # :nodoc: - ActiveRecord::Encryption.on_encrypted_attribute_declared do |klass, encrypted_attribute_name| - filter = [("#{klass.model_name.element}" if klass.name), encrypted_attribute_name.to_s].compact.join(".") - unless excluded_from_filter_parameters?(filter) - app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter) - klass.filter_attributes += [encrypted_attribute_name] - end - end - end - - private - def excluded_from_filter_parameters?(filter_parameter) - ActiveRecord::Encryption.config.excluded_from_filter_parameters.find { |excluded_filter| excluded_filter.to_s == filter_parameter } - end end end end diff --git a/activerecord/lib/active_record/encryption/contexts.rb b/activerecord/lib/active_record/encryption/contexts.rb index 8cf8ba2d1cd36..a55a1d407cc83 100644 --- a/activerecord/lib/active_record/encryption/contexts.rb +++ b/activerecord/lib/active_record/encryption/contexts.rb @@ -14,7 +14,7 @@ module Contexts extend ActiveSupport::Concern included do - mattr_reader :default_context, default: Context.new + mattr_accessor :default_context, default: Context.new thread_mattr_accessor :custom_contexts end @@ -66,6 +66,10 @@ def context def current_custom_context self.custom_contexts&.last end + + def reset_default_context + self.default_context = Context.new + end end end end diff --git a/activerecord/lib/active_record/encryption/derived_secret_key_provider.rb b/activerecord/lib/active_record/encryption/derived_secret_key_provider.rb index 043b97fa58608..1582d409e87ff 100644 --- a/activerecord/lib/active_record/encryption/derived_secret_key_provider.rb +++ b/activerecord/lib/active_record/encryption/derived_secret_key_provider.rb @@ -4,9 +4,15 @@ 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) }) + def initialize(passwords, key_generator: ActiveRecord::Encryption.key_generator) + super(Array(passwords).collect { |password| derive_key_from(password, using: key_generator) }) end + + private + def derive_key_from(password, using: key_generator) + secret = using.derive_key_from(password) + ActiveRecord::Encryption::Key.new(secret) + end end end end diff --git a/activerecord/lib/active_record/encryption/key_generator.rb b/activerecord/lib/active_record/encryption/key_generator.rb index f8e2655aec4c2..6411f48ecbbf5 100644 --- a/activerecord/lib/active_record/encryption/key_generator.rb +++ b/activerecord/lib/active_record/encryption/key_generator.rb @@ -6,6 +6,12 @@ module ActiveRecord module Encryption # Utility for generating and deriving random keys. class KeyGenerator + attr_reader :hash_digest_class + + def initialize(hash_digest_class: ActiveRecord::Encryption.config.hash_digest_class) + @hash_digest_class = hash_digest_class + end + # 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) @@ -30,7 +36,7 @@ def generate_random_hex_key(length: key_length) # # 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, hash_digest_class: ActiveRecord::Encryption.config.hash_digest_class) + ActiveSupport::KeyGenerator.new(password, hash_digest_class: hash_digest_class) .generate_key(key_derivation_salt, length) end diff --git a/activerecord/lib/active_record/encryption/scheme.rb b/activerecord/lib/active_record/encryption/scheme.rb index 175ed51070a07..9efdf3298ad4a 100644 --- a/activerecord/lib/active_record/encryption/scheme.rb +++ b/activerecord/lib/active_record/encryption/scheme.rb @@ -53,7 +53,7 @@ def merge(other_scheme) end def to_h - { key_provider: @key_provider_param, key: @key, deterministic: @deterministic, downcase: @downcase, ignore_case: @ignore_case, + { key_provider: @key_provider_param, deterministic: @deterministic, downcase: @downcase, ignore_case: @ignore_case, previous_schemes: @previous_schemes_param, **@context_properties }.compact end diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index a10464274bf66..7b3e48a45dd11 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -357,11 +357,17 @@ class Railtie < Rails::Railtie # :nodoc: end initializer "active_record_encryption.configuration" do |app| - ActiveRecord::Encryption.configure \ - primary_key: app.credentials.dig(:active_record_encryption, :primary_key), - deterministic_key: app.credentials.dig(:active_record_encryption, :deterministic_key), - key_derivation_salt: app.credentials.dig(:active_record_encryption, :key_derivation_salt), - **config.active_record.encryption + auto_filtered_parameters = ActiveRecord::Encryption::AutoFilteredParameters.new(app) + + config.after_initialize do |app| + ActiveRecord::Encryption.configure \ + primary_key: app.credentials.dig(:active_record_encryption, :primary_key), + deterministic_key: app.credentials.dig(:active_record_encryption, :deterministic_key), + key_derivation_salt: app.credentials.dig(:active_record_encryption, :key_derivation_salt), + **config.active_record.encryption + + auto_filtered_parameters.enable if ActiveRecord::Encryption.config.add_to_filter_parameters + end ActiveSupport.on_load(:active_record) do # Support extended queries for deterministic attributes and validations @@ -377,11 +383,6 @@ class Railtie < Rails::Railtie # :nodoc: ActiveRecord::Fixture.prepend ActiveRecord::Encryption::EncryptedFixtures end end - - # Filtered params - if ActiveRecord::Encryption.config.add_to_filter_parameters - ActiveRecord::Encryption.install_auto_filtered_parameters_hook(app) - end end initializer "active_record.query_log_tags_config" do |app| diff --git a/activerecord/test/cases/encryption/configurable_test.rb b/activerecord/test/cases/encryption/configurable_test.rb index 285ab56fd977b..c82a01063d823 100644 --- a/activerecord/test/cases/encryption/configurable_test.rb +++ b/activerecord/test/cases/encryption/configurable_test.rb @@ -43,23 +43,25 @@ class ActiveRecord::Encryption::ConfigurableTest < ActiveRecord::EncryptionTestC test "installing autofiltered parameters will add the encrypted attribute as a filter parameter using the dot notation" do application = OpenStruct.new(config: OpenStruct.new(filter_parameters: [])) - ActiveRecord::Encryption.install_auto_filtered_parameters_hook(application) - NamedPirate = Class.new(Pirate) do - self.table_name = "pirates" + with_auto_filtered_parameters(application) do + NamedPirate = Class.new(Pirate) do + self.table_name = "pirates" + end + NamedPirate.encrypts :catchphrase end - NamedPirate.encrypts :catchphrase assert_includes application.config.filter_parameters, "named_pirate.catchphrase" end test "installing autofiltered parameters will work with unnamed classes" do application = OpenStruct.new(config: OpenStruct.new(filter_parameters: [])) - ActiveRecord::Encryption.install_auto_filtered_parameters_hook(application) - Class.new(Pirate) do - self.table_name = "pirates" - encrypts :catchphrase + with_auto_filtered_parameters(application) do + Class.new(Pirate) do + self.table_name = "pirates" + encrypts :catchphrase + end end assert_includes application.config.filter_parameters, "catchphrase" @@ -69,15 +71,23 @@ class ActiveRecord::Encryption::ConfigurableTest < ActiveRecord::EncryptionTestC ActiveRecord::Encryption.config.excluded_from_filter_parameters = [:catchphrase] application = OpenStruct.new(config: OpenStruct.new(filter_parameters: [])) - ActiveRecord::Encryption.install_auto_filtered_parameters_hook(application) - Class.new(Pirate) do - self.table_name = "pirates" - encrypts :catchphrase + with_auto_filtered_parameters(application) do + Class.new(Pirate) do + self.table_name = "pirates" + encrypts :catchphrase + end end assert_equal application.config.filter_parameters, [] ActiveRecord::Encryption.config.excluded_from_filter_parameters = [] end + + private + def with_auto_filtered_parameters(application) + auto_filtered_parameters = ActiveRecord::Encryption::AutoFilteredParameters.new(application) + yield + auto_filtered_parameters.enable + end end diff --git a/activerecord/test/cases/encryption/encryptable_record_test.rb b/activerecord/test/cases/encryption/encryptable_record_test.rb index 3102658e84cd0..26dd0266c7d35 100644 --- a/activerecord/test/cases/encryption/encryptable_record_test.rb +++ b/activerecord/test/cases/encryption/encryptable_record_test.rb @@ -64,7 +64,7 @@ class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::Encryption end test "encrypts multiple attributes with different options at the same time" do - post = EncryptedPost.create!\ + post = EncryptedPost.create! \ title: title = "The Starfleet is here!", body: body = "

the Starfleet is here, we are safe now!

" @@ -308,7 +308,37 @@ def name assert_equal book, Marshal.load(Marshal.dump(book)) end + test "supports decrypting data encrypted non deterministically with SHA1 when digest class is SHA256" do + ActiveRecord::Encryption.configure \ + primary_key: "the primary key", + deterministic_key: "the deterministic key", + key_derivation_salt: "the salt", + support_sha1_for_non_deterministic_encryption: true + + key_provider_sha1 = build_derived_key_provider_with OpenSSL::Digest::SHA1 + key_provider_sha256 = build_derived_key_provider_with OpenSSL::Digest::SHA256 + + encrypted_post_class_sha_1 = Class.new(Post) do + self.table_name = "posts" + encrypts :title, key_provider: key_provider_sha1 + end + encrypted_post_class_sha_1.create! title: "Post 1", body: "The post body", type: nil + + encrypted_post_class_sha_256 = Class.new(Post) do + self.table_name = "posts" + encrypts :title, key_provider: key_provider_sha256 + end + + assert_equal "Post 1", encrypted_post_class_sha_256.last.title + end + private + def build_derived_key_provider_with(hash_digest_class) + ActiveRecord::Encryption.with_encryption_context(key_generator: ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: hash_digest_class)) do + ActiveRecord::Encryption::DerivedSecretKeyProvider.new(ActiveRecord::Encryption.config.primary_key) + end + end + class FailingKeyProvider def decryption_key(message) end diff --git a/activerecord/test/cases/encryption/key_generator_test.rb b/activerecord/test/cases/encryption/key_generator_test.rb index 28046526d4c7b..3a2714c8386f5 100644 --- a/activerecord/test/cases/encryption/key_generator_test.rb +++ b/activerecord/test/cases/encryption/key_generator_test.rb @@ -50,6 +50,6 @@ def assert_derive_key(secret, digest_class: OpenSSL::Digest::SHA256, length: 20) .generate_key(ActiveRecord::Encryption.config.key_derivation_salt, length) assert_equal length, expected_derived_key.length ActiveRecord::Encryption.config.hash_digest_class = digest_class - assert_equal expected_derived_key, @generator.derive_key_from(secret, length: length) + assert_equal expected_derived_key, ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: digest_class).derive_key_from(secret, length: length) end end diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md index 859e5c3f2f011..c960626ba49fc 100644 --- a/guides/source/active_record_encryption.md +++ b/guides/source/active_record_encryption.md @@ -480,6 +480,11 @@ The default encoding for attributes encrypted deterministically. You can disable The digest algorithm used to derive keys. `OpenSSL::Digest::SHA1` by default. +#### `config.active_record.encryption.support_sha1_for_non_deterministic_encryption` + +Supports decrypting data encrypted non-deterministically with a digest class SHA1. Default is false, which +means it will only support the digest algorithm configured in `config.active_record.encryption.hash_digest_class`. + ### Encryption Contexts An encryption context defines the encryption components that are used in a given moment. There is a default encryption context based on your global configuration, but you can configure a custom context for a given attribute or when running a specific block of code. diff --git a/guides/source/configuring.md b/guides/source/configuring.md index a772cb62cf170..ffe39d218b624 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -69,6 +69,7 @@ Below are the default values associated with each target version. In cases of co - [`config.active_record.belongs_to_required_validates_foreign_key`](#config-active-record-belongs-to-required-validates-foreign-key): `false` - [`config.active_record.default_column_serializer`](#config-active-record-default-column-serializer): `nil` - [`config.active_record.encryption.hash_digest_class`](#config-active-record-encryption-hash-digest-class): `OpenSSL::Digest::SHA256` +- [`config.active_record.encryption.support_sha1_for_non_deterministic_encryption`](#config-active-record-encryption-support-sha1-for-non-deterministic-encryption): `false` - [`config.active_record.marshalling_format_version`](#config-active-record-marshalling-format-version): `7.1` - [`config.active_record.query_log_tags_format`](#config-active-record-query-log-tags-format): `:sqlcommenter` - [`config.active_record.raise_on_assign_to_attr_readonly`](#config-active-record-raise-on-assign-to-attr-readonly): `true` @@ -1499,6 +1500,18 @@ database schema dump. Defaults to `/^fk_rails_[0-9a-f]{10}$/`. | (original) | `OpenSSL::Digest::SHA1` | | 7.1 | `OpenSSL::Digest::SHA256` | +#### `config.active_record.encryption.support_sha1_for_non_deterministic_encryption` + +Enables support for decrypting existing data encrypted using a SHA-1 digest class. When `false`, +it will only support the digest configured in `config.active_record.encryption.hash_digest_class`. + + The default value depends on the `config.load_defaults` target version: + + | Starting with version | The default value is | + |-----------------------|----------------------| + | (original) | `true` | + | 7.1 | `false` | + ### Configuring Action Controller `config.action_controller` includes a number of configuration settings: diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index 4ab18b9d3c259..00d2acb035fbd 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -283,6 +283,7 @@ def load_defaults(target_version) active_record.before_committed_on_all_records = true active_record.default_column_serializer = nil active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256 + active_record.encryption.support_sha1_for_non_deterministic_encryption = false active_record.marshalling_format_version = 7.1 active_record.run_after_transaction_callbacks_in_order_defined = true end diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt index f27dec316109e..3e78878c35312 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt @@ -30,13 +30,18 @@ # Rails.application.config.action_controller.allow_deprecated_parameters_hash_equality = false # Active Record Encryption now uses SHA-256 as its hash digest algorithm. Important: If you have -# data encrypted with previous versions, you should not set the new default or the existing data -# will fail to decrypt. In this case, if you load the new 7.1 defaults, you need to configure the -# previous algorithm SHA-1: +# data encrypted with previous Rails versions, there are two scenarios to consider: +# +# 1. If you have +config.active_support.key_generator_hash_digest_class+ configured as SHA1 (the default +# before Rails 7.0), you need to configure SHA-1 for Active Record Encryption too: # Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA1 -# Alternatively, if you don't have data encrypted previously, you can configure the new digest for -# Active Record Encryption with: +# 2. If you have +config.active_support.key_generator_hash_digest_class+ configured as SHA256 (the new default +# in 7.0), then you need to configure SHA-256 for Active Record Encryption: # Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256 +# +# If you don't currently have data encrypted with Active Record encryption, you can disable this setting to +# configure the default behavior starting 7.1+: +# Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = false # No longer run after_commit callbacks on the first of multiple Active Record # instances to save changes to the same database row within a transaction. diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index a862b407666bb..534e3b379236b 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -4345,6 +4345,16 @@ def new(app); self; end assert_match(/Cannot assign to `load_defaults`, it is a configuration method/, error.message) end + test "allows initializer to set active_record_encryption.configuration" do + app_file "config/initializers/active_record_encryption.rb", <<-RUBY + Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA1 + RUBY + + app "development" + + assert_equal OpenSSL::Digest::SHA1, ActiveRecord::Encryption.config.hash_digest_class + end + private def set_custom_config(contents, config_source = "custom".inspect) app_file "config/custom.yml", contents