Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Rails7.1へのアップグレードで発生した暗号化のエラーとその対応

こんにちは。サーバーグループ エンジニアの山田です。

今回はRails7.0からRails7.1へのアップグレードを行なった際に、ActiveRecord Encryptionで発生したエラーとその対応について紹介します。

同様のエラーに遭遇した方の参考になれば幸いです。

ActiveRecord Encryptionによる属性の暗号化

本題に入る前の前提知識としてActiveRecord Encryptionについてふれます。

ActiveRecordでは属性を暗号化して保存する仕組みが用意されています。

例えばUserのemailを暗号化して保存しようとした場合、以下のように encrypts で属性を指定すること実現できます。

class User < ApplicationRecord
  encrypts :email
end

以下のように暗号化していない場合と同様に保存したり元の値を取得できます。

User.create(email: "hoge@studyplus.jp")
User.last.email
=> "hoge@studyplus.jp"

準備として暗号化で使用するランダムなキーセットを設定しておく必要があります。

$ bin/rails db:encryption:init
Add this entry to the credentials of the target environment:

active_record_encryption:
  primary_key: CEJ8coRHt4obyziTV0ZRBpY6CYMtdfq7
  deterministic_key: Ywp91AWdNBS886dQg6SSx5EgcRtsvX6X
  key_derivation_salt: joWvNGHMA0dTpO91xbeIwpl4UT1jLnu6

生成したキーをRails credentialsにコピーして貼り付けることで保存できます。また、環境変数などで設定も可能です。

詳しくはRailsガイドのActive Record と暗号化を参照ください。

決定論的暗号化と非決定論的暗号化

ActiveRecord Encryptionで使用する暗号化は大きく分けて決定論的暗号化と非決定論的暗号化があります。

Railsガイド 2.3 決定論的暗号化と非決定論的暗号化についてに書かれているようにActiveRecord暗号化ではデフォルトでは非決定論的暗号化が使用されますが、決定論的暗号化を用いることもできます。

ActiveRecord暗号化では、デフォルトで非決定論的な(non-deterministic)暗号化を用います。ここで言う非決定論的とは、同じコンテンツを同じパスワードで暗号化しても、暗号化のたびに異なる暗号文が生成されるという意味です。非決定論的な暗号化手法によって、暗号解析の難易度を高めてデータベースへのクエリを不可能にすることで、セキュリティを向上させます。

今回の対応では非決定論的暗号化を使用したアプリケーションで発生した事象のため、以降は非決定論的な暗号化を前提に記載しています。

発生した事象

ここからが本題です。

Rails7.0でActiveRecord Encryptionによる暗号化を使用していたアプリケーションをRails7.1にアップグレードしようとしました。その際に、暗号化した属性の値を参照しようとする処理でActiveRecord::Encryption::Errors::Decryptionが発生しました。

/usr/local/bundle/gems/activerecord-7.1.0/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.0/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.0/lib/active_record/encryption/cipher/aes256_gcm.rb:75:in `final': OpenSSL::Cipher::CipherError
/usr/local/bundle/gems/activerecord-7.1.0/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.0/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.0/lib/active_record/encryption/cipher/aes256_gcm.rb:75:in `final': OpenSSL::Cipher::CipherError

例外のクラスからわかるように暗号化した属性の復号化に失敗しています。

エラーの原因

原因を調査したところRails7.1からActiveRecord Encryptionで使用されるHash Digest Algorithmのデフォルトが変更されたためでした。OpenSSL::Digest::SHA1からOpenSSL::Digest::SHA256へ変わったことでSHA1を使って暗号化していた属性がSHA256で複合化できなくなりました。(以降はそれぞれSHA1、SHA256のように短縮して書きます。)

SHA256に変わった経緯

元々Rails7.0のActiveRecord EncryptionではHash Digest AlgorithmとしてSHA256を使う想定でしたが、バグにより非決定論的暗号化の場合はデフォルトでSHA1が使われてしまっていました。

以下のConversationに詳細が書かれています。

github.com

There is currently a problem with Active Record encryption for users updating from 7.0 to 7.1 Before #44873, data encrypted with non-deterministic encryption was always using SHA-1. The reason is that ActiveSupport::KeyGenerator.hash_digest_class was 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 existing users are using SHA256 for non deterministic encryption, and SHA1 for deterministic encryption.

しかしこの挙動は意図したものとは異なりRails7.1では config.active_record.encryption.hash_digest_class が導入されデフォルトでSHA256が使われるようになりました。
そのためSHA256が使用される前に非決定論的暗号化を利用していた場合、アルゴリズムが異なるためRails7.1にアップデートするとエラーが発生します。

対応方法

config.active_record.encryption に用意された以下のオプションを使うことでエラー回避が可能です。

hash_digest_class

config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA1

hash_digest_classのデフォルト値はOpenSSL::Digest::SHA256ですが、OpenSSL::Digest::SHA1を指定し今後もSHA1を使い続けることが可能です。

support_sha1_for_non_deterministic_encryption

config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

このオプションを有効化すると暗号化でSHA256を使用しつつ復号化はSHA256とSHA1で暗号化された属性の両方を複合化できる状態となります。

採用した方法

どちらの設定も暫定対応としては問題ありません。ただ今後もSHA1がサポートされ続けるとは限らないため 、以下の流れで最終的にSHA256 のみが使われるように対応しました。

  1. 以下の設定を入れてSHA1で生成された古い値を読み込めるようにしつつ新規で保存される場合はSHA256で暗号化した値が保存されるようにする
    • Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256
    • support_sha1_for_non_deterministic_encryption = true
  2. SHA1で暗号化された属性をSHA256で暗号化し直して保存するデータマイグレーションの実施
  3. support_sha1_for_non_deterministic_encryption = trueを外し、SHA256のみが使われる状態にする

以下はデータマイグレーションの実装例になります。

# Userモデルのlast_name, first_nameに対して更新する例

%i[last_name first_name].each do |attr|
  recrypt_attribute(attr)
end

def recrypt_attribute(attr)
  encryptor = ActiveRecord::Encryption::Encryptor.new
  sha1_key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(ActiveRecord::Encryption.config.primary_key,
  key_generator: ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: OpenSSL::Digest::SHA1))
  sha256_key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(ActiveRecord::Encryption.config.primary_key,
  key_generator: ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: OpenSSL::Digest::SHA256))

  User.in_batches do |relation|
    records = []
    relation.each do |user|
      next if user[attr].blank? || !need_recrypt?(user, attr, encryptor, sha256_key_provider)

      raw_value = user.read_attribute_before_type_cast(attr)
      record = { id: user.id }
      record[attr] = encryptor.decrypt(raw_value, key_provider: sha1_key_provider)
      records << record
    end
    User.upsert_all records, update_only: [attr], record_timestamps: false
  end
end

def need_recrypt?(record, attr, encryptor, sha256_key_provider)
  msg = record.read_attribute_before_type_cast(attr)
  encryptor.decrypt(msg, key_provider: sha256_key_provider)
  false
rescue ActiveRecord::Encryption::Errors::Decryption
  true
end

need_recrypt?メソッドで復号化前の値をSHA256でdecript可能か確認し、できない場合は値を書き換える対象のレコードとしています。

今回対応したテーブルはデータ量が多くなかったため上記のようなコードをRakeタスクで実行して一気に更新しました。
レコード数が多いテーブルの場合は、パフォーマンス面やDBの負荷への考慮が必要となるかもしれません。

更新完了後にsupport_sha1_for_non_deterministic_encryption = trueを外すことで、SHA1を扱う必要がなくなり、7.1のデフォルトと同様のSHA256のみで暗号化が使える状態となりました🎉

まとめ

ActiveRecord EncryptionをRails7.0で使っている状態で7.1へアップグレードした際に発生するエラーの対応例を紹介しました。

簡単に暗号化できるようになり便利な機能ですが、今回紹介したように使い始めるバージョンによっては注意が必要です。同様のエラーに遭遇した方の参考になれば幸いです。