rails: Upgrade to Rails 7.1 alpha breaks ActiveRecord attribute encryption (deterministic)

Steps to reproduce

In Rails 7.0:

create_table "my_models", force: :cascade do |t|
  t.string "my_attribute"
end

class MyModel < ApplicationRecord
  encrypts :my_attribute, deterministic: true
end

MyModel.create!(my_attribute: "my value")

model = MyModel.last
model.my_attribute == "my value" # true

Upgrade to Rails 7.1-alpha and run:

model = MyModel.last # raises exception

ActiveRecord encryption configuration has not changed in the meanwhile. I don’t know with what version of Rails the ActiveRecord encryption configuration has been created. Only deterministically encrypted attributes seem to be affected by this. We also have non-deterministically encrypted attributes and those still work after upgrading to Rails 7.1-alpha.

Expected behavior

Deterministically encrypted attribute should be decrypted properly.

Actual behavior

An error is raised while attribute is being decrypted:

/Users/someuser/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/bundler/gems/rails-403e4e5b5639/activerecord/lib/active_record/encryption/encryptor.rb:58:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/Users/someuser/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/bundler/gems/rails-403e4e5b5639/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb:79:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/Users/someuser/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/bundler/gems/rails-403e4e5b5639/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb:75:in `final': OpenSSL::Cipher::CipherError

System configuration

Rails version: 7.1.0-alpha

Ruby version: 3.2.1

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 1
  • Comments: 39 (29 by maintainers)

Commits related to this issue

Most upvoted comments

If you have some attributes encrypted using SHA1 and others using SHA256, you will get a ActiveRecord::Encryption::Errors::Decryption, irrelevant what hash_digest_class is set to.

To resolve this, set the following option in addition to config.load_defaults 7.1:

config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

Then re-encrypt all your models having encrypted attributes:

MyModelWithEncryptedAttributes.find_each(&:encrypt)

You can then disable/remove the above option again.

I feel like new_framework_defaults_7_1.rb should be updated to reflect this. Going through the new framework defaults one by one, then using config.load_defaults 7.1 will cause support_sha1_for_non_deterministic_encryption = false, no? But config.load_defaults 7.0 causes support_sha1_for_non_deterministic_encryption = true. To me, there should be a warning to explicitly set that configuration to true if you have encrypted data and are coming from a prior version of Rails, otherwise your app will fail to decrypt old data when loading 7.1 defaults. Right now, there’s no instructions on setting it to true prior to calling config.load_defaults 7.1.

Having to re-encrypt data can be a large undertaking when you have hundreds of millions of records, so I feel like this needs to be more apparent when upgrading, because right now I feel like it’s glossed over and missing some important details.

Still trying to dig into this issue for https://github.com/keygen-sh/keygen-api/pull/764, so apologies if any of this is incorrect.

Edit: I now notice this is the same or similar issue reported by @ezekg and documented in #49610. Sadly I upgraded before that change was merged. I’m leaving this here for those that also missed it and need to fix their database.


I’ve run into this issue after upgrading to 7.0.

When running Rails 7.1, if I try to load a record that was created by Rails 7.0 and try to read an attribute that uses deterministic: true, I get a CipherError

/usr/local/bundle/gems/activerecord-7.1.1/lib/active_record/encryption/encryptor.rb:58:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/usr/local/bundle/gems/activerecord-7.1.1/lib/active_record/encryption/cipher/aes256_gcm.rb:79:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/usr/local/bundle/gems/activerecord-7.1.1/lib/active_record/encryption/cipher/aes256_gcm.rb:75:in `final': OpenSSL::Cipher::CipherError

I’m using load_defaults(7.0) and we’ve never had any custom encryption configuration.

My problem is I didn’t notice this until deploying it and since then, new records have been created. So I can read the data on new records but not old ones. Looking at new_framework_defaults_7.1.rb, I didn’t interpret item 2 of the encryption options as something I’d actually have to take action on since I didn’t have any key_generator_hash_digest_class config, and anyway, I understood this file to be things you opt in to later on and not actions that must be taken before upgrading.

I was lucky in that I only had a single column with deterministic encryption, and not being able to read the old values wasn’t an immediate emergency. I came up with the following database migration to fix the old records and I wanted to share it here for anyone else finding this issue from Google.

Step 1 is un-comment this line in new_framework_defaults_7.1.rb

Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256

Then, fix existing data:

class Reencrypt < ActiveRecord::Migration[7.1]
  def up
    recrypt(MyModel, :secret, old_hash_digest_class: OpenSSL::Digest::SHA1)
  end

  def down
    recrypt(MyModel, :secret, old_hash_digest_class: OpenSSL::Digest::SHA256)
  end

  private

  def recrypt(model, attr, old_hash_digest_class:)
    encryptor = ActiveRecord::Encryption::Encryptor.new
    key_provider = make_key_provider(hash_digest_class: old_hash_digest_class)

    model
      .where.not(attr => nil).select do |record|
        # trying to read the attribute will trigger an error if it needs to be re-encrypted
        record.read_attribute(attr)
        false
      rescue ActiveRecord::Encryption::Errors::Decryption
        true
      end
      .each do |record|
        msg = record.read_attribute_before_type_cast(attr)
        clear_value = encryptor.decrypt(msg, key_provider:)

        record.update_column(attr, clear_value)

        # Ensure it can be read-back
        record.reload.read_attribute(attr)
      end
  end

  def make_key_provider(hash_digest_class:)
    deterministic_key = Rails.application.config.active_record.encryption.fetch(:deterministic_key)
    key_generator = ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class:)

    ActiveRecord::Encryption::DerivedSecretKeyProvider.new(deterministic_key, key_generator:)
  end
end

If you have some attributes encrypted using SHA1 and others using SHA256, you will get a ActiveRecord::Encryption::Errors::Decryption, irrelevant what hash_digest_class is set to.

To resolve this, set the following option in addition to config.load_defaults 7.1:

config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

Then re-encrypt all your models having encrypted attributes:

MyModelWithEncryptedAttributes.find_each(&:encrypt)

You can then disable/remove the above option again.

Yeah, I confirmed the issue on my side. The problem is that, before #44873, data encrypted with non-deterministic encryption was always using SHA-1. The reason is that ActiveSupport::KeyGenerator.hash_digest_class is set in an after_initialize block in the railtie config, but encryption config was running before that, so it was effectively using the previous default SHA1. That means that we were using SHA256 for non deterministic, and SHA1 for deterministic encryption as you saw @duduribeiro.

The initialization problem is being fixed in #48520, but we now have the problem of existing data encrypted with two hash digests. This will affect everyone using the default key providers in 7.0.x.

I’ll prepare a patch for this. My first idea is adding an option to support decrypting data with both digest classes, so that we offer an upgrade path where things don’t break for existing users. We have support in place to support different encryption schemes, so it shouldn’t be a big change.

@duduribeiro I’ll work off your changes here https://github.com/rails/rails/pull/48520 in a new branch, since this change should happen at the same time.

Unsurprisingly:

config/initializers/new_framework_defaults_7_1.rb


# 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

triggers the issue.


Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

resolves the issue.

Surprised to see that data saved using the newer revision was encrypted using SHA1 while data saved using the older revision was encrypted using SHA256. Perhaps there was some defaults changing across revisions or I’m mistaken on the revisions that were active when the data was saved.

@seanabrahams maybe you went to this specific scenario https://github.com/rails/rails/issues/48204#issuecomment-1598373096

Hi @jorgemanrubia. I can confirm that the fix of https://github.com/rails/rails/pull/48577 fixes our problem! Thanks

Hello, this happened to me with non-deterministic encryption as well

In order to fix, I’ve used the migration at https://github.com/rails/rails/issues/48204#issuecomment-1779505395 with this relevant change

-deterministic_key = Rails.application.config.active_record.encryption.fetch(:deterministic_key)
+primary_key = Rails.application.config.active_record.encryption.fetch(:primary_key)

# ...

-ActiveRecord::Encryption::DerivedSecretKeyProvider.new(deterministic_key, key_generator:)
+ActiveRecord::Encryption::DerivedSecretKeyProvider.new(primary_key, key_generator:)

Hi @jorgemanrubia. I’m afraid I found another issue related to this.

Prior to Rails 7.1 alpha:

MyModel.create!(my_attribute: "my value")
MyModel.find_by(my_attribute: "my value") # returns instance of MyModel with my_attribute == "my value"

Switch to Rails 7.1 alpha:

MyModel.find_by(my_attribute: "my value") # returns nil

Any ideas?

Thanks for the report @bforma. I’ll get this solved this week. For the record, this should not be necessary to add (that’s implicit when coming from 7.0 defaults):

config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

👋 @bforma can you confirm that this repo script is correct? I am unable to reproduce what you are seeing when pointing to rails main

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails", github: "rails/rails", branch: "main"
  # gem 'rails', '~> 7.0'
  # gem "rails", path: "~/nickborromeo/rails"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Encryption.configure(
  primary_key: SecureRandom.alphanumeric(32),
  deterministic_key: SecureRandom.alphanumeric(32),
  key_derivation_salt: SecureRandom.alphanumeric(32),
  support_unencrypted_data: true,
)

ActiveRecord::Schema.define do
  create_table :posts, force: :cascade do |t|
    t.string :title
  end
end

class Post < ActiveRecord::Base
  encrypts :title, deterministic: true
end

class BugTest < Minitest::Test
  def test_association_stuff
    Post.create!(title: "my title")
    post = Post.last
   
    assert_equal post.title, "my title"
  end
end