Rails 7.0 アップグレード時はダイジェストクラスの設定に気を付けよう
Photo by Monika Grabkowska on Unsplash
「何もしてないのに壊れた」とはよく言ったものですが、何もしていなければ壊れてしまうのが Web アプリケーションの怖いところです。Rails は絶えず改良が加えられ継続的に新バージョンがリリースされていますが、一方で開発リソースの制約から古いバージョンのサポートは打ち切られ、基本的にはバグや脆弱性の修正がなされない運命にあるため、それを利用する我々もバージョンアップグレードに対して継続的に取り組んで行かなければなりません。走り続けていなければ、立ち止まる事すら許されないのです。
とはいえ Rails アップグレードも一筋縄にいく作業ではなく、ただ漫然とこなしていれば思わぬ所で足を掬われるのも事実で、本記事では Rails 7.0 へのアップグレード時に注意が必要な点としてダイジェストクラスのデフォルト設定を紹介します。一見何の変哲もなさそうな変更点でも認証系に思わぬ影響を与える可能性がありますから、この記事が皆さんの安全なアップグレード作業の助けになれば幸いです。
Rails 7.0 の注意を要する変更点
本記事で注意喚起したい Rails 7.0 の変更点は、ActiveSupport::Digest
で用いられるメッセージダイジェストクラスが SHA1 から SHA256 に変更された点です。Rails アップグレードガイドを見ると Etag の変更やキャッシュキーなどに影響があると書かれていますね。
実は ActiveSupport::Digest
で用いられるメッセージダイジェストクラスの実装は ActiveSupport::MessageEncryptor
等で用いる暗号化キーにも影響があり、対策しなければ以前のバージョンの Rails で暗号化した Cookie が復号できなくなる場合があります。実際に Rails 7.0 の実装を見て確かめてみましょう。
ActionController では暗号化した Cookie にアクセスする場合は cookies.encrypted
という jar を用いますが、この jar がどのようなアルゴリズムで暗号化を行っているのか確認してみます。cookies.encrypted
の実装を辿ると、この jar は EncryptedKeyRotatingCookieJar
のインスタンスであることが分かります。
def encrypted
@encrypted ||= EncryptedKeyRotatingCookieJar.new(self)
end
では EncryptedKeyRotatingCookieJar
がどのようなアルゴリズムで暗号化を行っているのかを見ると、ActiveSupport::KeyGenerator
で生成した鍵を使って、AES256 CBC等の共通鍵暗号アルゴリズムを用いている事が分かります。
if request.use_authenticated_cookie_encryption
key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len)
@encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER)
else
key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-cbc")
secret = request.key_generator.generate_key(request.encrypted_cookie_salt, key_len)
sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt)
@encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: SERIALIZER)
end
実は、ActiveSupport::Digest
で用いられるメッセージダイジェストクラスが Cookie の暗号化・復号に影響を与えているのは、ActiveSupport::KeyGenerator
で共通鍵を生成する部分です。ActiveSupport::KeyGenerator
は PKCS#5 の標準に則って PBKDF2 と呼ばれるアルゴリズムで鍵を生成していますが、これは与えられたパスワードにソルトを添加した上で暗号学的ハッシュ関数を何千回何万回と適用してパスワードの推察を困難にするものです。
def generate_key(salt, key_size = 64)
OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, @iterations, key_size, @hash_digest_class.new)
end
https://en.wikipedia.org/wiki/PBKDF2
PBKDF2 はある意味でパスワード、ソルトと、暗号学的ハッシュ関数の実装を受け取る純粋な関数とも言えますが、ここに与えるハッシュ関数の実装(即ちメッセージダイジェストクラス)を SHA1 から SHA256 に変更してしまうと、当然戻り値である共通鍵も変化してしまいます。全く別の鍵を使って共通鍵暗号を復号できる訳はありませんから、Rails アップグレード前に SHA1 を使って生成した鍵で暗号化した Cookie は、SHA256 を使う変更の入ったRails アップグレード後には読めなくなってしまいますよね。
破局的なシナリオ
単一の Rails アプリケーションで構成されたモノリシックなサービスであれば、Rails アップグレード前に暗号化した Cookie が読めなくなると言っても精々一度セッションが消える程度の被害で済みますが、共通鍵を複数のマイクロサービスで共有して暗号化した Cookie を読み書きしている場合は悲惨な事態が起こります。
例えばメッセージダイジェストクラスの設定をデフォルトのまま変更していない、Rails 6.1 のマイクロサービス A と B が存在した際に後者だけ Rails 7.0 にアップグレードしてしまうと、A は B が書いた Cookie を解読不能なものとして破棄する上、B も A が書いた Cookie を解読不能なものとして破棄するため、一切セッションが共有できなくなってしまいます。この時の動作はユーザーの目にも頻繁にログアウトを繰り返している様に映りますから、体験の悪化も相当なものでしょう。
この様な事態を招かず安全にメッセージダイジェストクラスの設定を変更するには、Cookie 設定のローテーションを用いる手法が挙げられます。
先程挙げた2つのマイクロサービスが存在する場合のマイグレーション方法を考えるなら、一旦暗号化した Cookie の書き込みには SHA1 を使う設定のまま読み込み時には SHA256 を使った暗号化 Cookie も認識するよう変更し、両方のマイクロサービスの設定が変更された後で書き込み時にも SHA256 を使うよう変更、SHA1 を使って暗号化した Cookie が失効するのを待って SHA256 を使った暗号化 Cookie だけ認識するよう変更する流れになるでしょうか。
セキュリティ対策のすすめ
そもそも Rails 7.0 でデフォルトのメッセージダイジェストクラスの座から降りた SHA-1 ですが、2017年には Google が実際に衝突攻撃を成功させており、もはや暗号学的ハッシュ関数としては時代遅れの代物です。
Web アプリケーションを開発する以上我々には積極的にセキュリティ対策を行う義務があるのはもちろんですが、今回槍玉に上げたようなセキュアでないデフォルト設定は Rails に限らず将来的に変更される可能性が高いと考えられるので、将来への投資と考えて見直してみるのも良いかもしれないですね。
まとめ
本記事では Rails 7.0 へのアップグレード時に注意が必要な点として、ダイジェストクラスのデフォルト設定を紹介しました。一見何の変哲も無さそうな変更点でも思わぬ落とし穴が潜んでいるのが Rails アップグレードの大変な所ですが、この記事が皆さんの安全なアップグレード作業の助けになれば幸いです。