株式会社iCAREでWebアプリケーションエンジニアをしているkossyと申します!
この記事は、iCARE Dev Advent Calendar 2023の20日目のものになります!
はじめに
皆さん、deviseは使ったことがありますでしょうか?
Railsで認証機能を実装しようとするときにまず選択肢に上がる、2023/12時点で52万のアプリケーションで使われていて、その歴史も長い(0.1.0のリリース日は2009/10/21でした)Gemです。
たくさんのアプリケーションで使われているGemですが、非常に多機能で、内部のソースコードをじっくり読むことも、業務で必要にならない限りはあまりないのではないでしょうか。
そんな、内部の動きは知らずともやりたいことが実現できてしまうdeviseの中でも、今回はtoken_generatorの仕組みを調べてみようと思っています。
環境
- devise 4.9.2
ソースコード
まずはソースコード全体を眺めてみます。
# frozen_string_literal: true
require 'openssl'
module Devise
class TokenGenerator
def initialize(key_generator, digest = "SHA256")
@key_generator = key_generator
@digest = digest
end
def digest(klass, column, value)
value.present? && OpenSSL::HMAC.hexdigest(@digest, key_for(column), value.to_s)
end
def generate(klass, column)
key = key_for(column)
loop do
raw = Devise.friendly_token
enc = OpenSSL::HMAC.hexdigest(@digest, key, raw)
break [raw, enc] unless klass.to_adapter.find_first({ column => enc })
end
end
private
def key_for(column)
@key_generator.generate_key("Devise #{column}")
end
end
end
initializeやprivateのメソッドを含め、4つのメソッドで構成されたシンプルな設計になっていますね。
lib/devise.rbを見てわかるように、deviseを利用しているアプリケーションであればどこからでも使えるモジュールになっています。
では initialize メソッドから見ていきましょう。
initialize
def initialize(key_generator, digest = "SHA256")
@key_generator = key_generator
@digest = digest
end
deviseの中での使われ方は以下のコードでした。
Devise::TokenGenerator.new(
ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key))
)
ActiveSupport::CachingKeyGenerator
や ActiveSupport::KeyGenerator
は deviseの範囲外かと思いますが、折角なので読んでみましょう。
ActiveSupport::KeyGenerator
コード内にコメントがあったので日本語訳してみます。
KeyGenerator は、OpenSSL の PBKDF2 実装の単純なラッパーです。
特定のシークレットからさまざまな目的で多数のキーを導出するために使用できます。
これにより、Rails アプリケーションは単一の安全なシークレットを持つことができますが、その再利用は避けられます。
複数の互換性のないコンテキストでキーを使用します。
情報の補完を目的に、ChatGPTにも説明していただきました。
ActiveSupport::KeyGeneratorは、Ruby on Railsの一部であるActive Supportライブラリに含まれるモジュールです。
このモジュールは、暗号化キーを生成するために使用されます。
具体的には、秘密キーとして使用するための一貫した暗号キーを生成するために使用されます。
ActiveSupport::KeyGeneratorは、指定されたシークレット(秘密の基本文字列)と塩(salt)を使用して、安全なキーを生成します。
このキーは、セッション、クッキー、トークン、その他の暗号化が必要なデータを扱う際に使用されます。
KeyGeneratorは、PBKDF2アルゴリズムを使用して、
与えられたシークレットと塩から安全なキーを導出します。
これにより、攻撃者が元のシークレットを推測することが非常に困難になり、
アプリケーションのセキュリティが強化されます。
例えば、Railsのアプリケーションでsecret_key_baseを生成する場合にActiveSupport::KeyGeneratorが使われることがあります。
これはRailsアプリケーションのセッションやクッキーを保護するための基本的なキーとして機能します。
塩(salt)は学習の限界なんでしょうか、、、
コードはこちらです。
コンソールで試してみました。
# SecureRandom.hex(64)した値を記載していて、本番運用しているkeyではないです
secret_key = Devise.secret_key
=> "032fc2a9dbe1e2b89b650a534512966f65beff6cae3b1fdedec8a75c8885fbeee2c53d6badeac548ed43b9148c5be38d979a9f282c3d0d56f6ff419cc00e4dd5"
ActiveSupport::KeyGenerator.new(secret_key)
=>
#<ActiveSupport::KeyGenerator:0x0000560baedc6698
@hash_digest_class=OpenSSL::Digest::SHA1,
@iterations=65536,
@secret="032fc2a9dbe1e2b89b650a534512966f65beff6cae3b1fdedec8a75c8885fbeee2c53d6badeac548ed43b9148c5be38d979a9f282c3d0d56f6ff419cc00e4dd5">
keyの発行は、publicメソッドとして定義されている generate_keyメソッドを使うようでした。ここでは掲載は割愛します。
ActiveSupport::CachingKeyGenerator
コード内にコメントがあったのですが、読んでも存在意図がよくわからなかったため、ChatGPTに助けてもらいました。
ActiveSupport::CachingKeyGeneratorは、Ruby on Railsフレームワークの一部であり、
キー生成プロセスの効率を向上させるために用いられるクラスです。
このクラスは、ActiveSupport::KeyGeneratorをラップして、
生成されたキーをメモリ内でキャッシュする機能を提供します。
ActiveSupport::CachingKeyGeneratorの主な役割は、
暗号キーを生成し、それをキャッシュして再利用することです。
このクラスは、指定された秘密キーと塩(salt)を使用してキーを生成し、
同じ引数での後続のキー生成リクエストに対してキャッシュからキーを提供します。
これにより、同じパラメータで何度もキーを生成する際の計算コストを削減できます。
キー生成、特にPBKDF2のような計算コストの高いキー導出関数を使用する場合、
キーの生成は時間がかかり、リソースを大量に消費する可能性があります。
特に、Webアプリケーションではリクエスト毎にキーを生成する必要がある場合が多く、
これがパフォーマンスのボトルネックになることがあります。
ActiveSupport::CachingKeyGeneratorを使用することで、次の利点が得られます:
・パフォーマンスの向上: 同じパラメータでのキー生成は、
初回のみ計算され、その後はキャッシュから迅速に取得されます。
これにより、アプリケーションのレスポンスタイムが改善されます。
・リソースの節約: キャッシュを活用することで、サーバーのCPUとメモリの使用量を削減できます。
このクラスは、特にセッションやクッキーの暗号化、トークンの生成など、
同じキーを繰り返し生成する必要がある場面で非常に役立ちます。
キー生成のコストを削減することで、全体的なアプリケーションの効率と
パフォーマンスが向上します。
要はkey生成を何度も行ってパフォーマンス劣化を招かないためにcacheするということのようです。
こちらもコンソールで試してみます。
ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key))
=>
#<ActiveSupport::CachingKeyGenerator:0x000055a898925fe0
@cache_keys=#<Concurrent::Map:0x000055a898925f90 entries=0 default_proc=nil>,
@key_generator=
#<ActiveSupport::KeyGenerator:0x000055a8989260d0
@hash_digest_class=OpenSSL::Digest::SHA1,
@iterations=65536,
@secret="032fc2a9dbe1e2b89b650a534512966f65beff6cae3b1fdedec8a75c8885fbeee2c53d6badeac548ed43b9148c5be38d979a9f282c3d0d56f6ff419cc00e4dd5">>
ActiveSupport::CachingKeyGeneratorクラスのインスタンスにpublicメソッドとして定義されている generate_key
メソッドは、cacheしたkeyを返す実装になっていました。
# File activesupport/lib/active_support/key_generator.rb, line 62
def generate_key(*args)
@cache_keys[args.join("|")] ||= @key_generator.generate_key(*args)
end
では改めて Devise::TokenGenerator.new
をコンソールで試してみます。
token_generator = Devise::TokenGenerator.new(ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key)))
=>
#<Devise::TokenGenerator:0x00007fcbcce42970
@digest="SHA256",
@key_generator=
#<ActiveSupport::CachingKeyGenerator:0x00007fcbcce42a38
@cache_keys=#<Concurrent::Map:0x00007fcbcce42a10 entries=0 default_proc=nil>,
@key_generator=
#<ActiveSupport::KeyGenerator:0x00007fcbcce42a88
@hash_digest_class=OpenSSL::Digest::SHA1,
@iterations=65536,
@secret="032fc2a9dbe1e2b89b650a534512966f65beff6cae3b1fdedec8a75c8885fbeee2c53d6badeac548ed43b9148c5be38d979a9f282c3d0d56f6ff419cc00e4dd5">>>
token_generator.generate(User, :reset_password_token)
=> ["aamV_uCaoV_xonPKXohL", "2311e1dddbe8598e17e2246f7dd4b16aee49e91d1418deee59d9d7c829cd5aae"]
generateメソッドは後述しますが、tokenの平文と暗号化したtokenを返すメソッドです。
ブラックボックスだった部分の仕組みがちょっとでもわかると気持ちがいいですね。
次は digest
メソッドを読んでみます。
digest
def digest(klass, column, value)
value.present? && OpenSSL::HMAC.hexdigest(@digest, key_for(column), value.to_s)
end
引数で受け取った平文のtokenをハッシュ関数を使ってハッシュ化します。同じ値が引数で渡されれば同じハッシュ値が返るので、結果としてDBに保存されたハッシュ値を持つユーザーを検索するための値が得られるというわけです。
コンソールで試してみます。
# tokenを生成する
Devise.token_generator.generate(User, :reset_password_token)
=>
["jgn9x4LH8aptzB2V7rBP", "822bf2b8a9a2b1306caa533f025f5a58c8d252f4705207ff0e80da3750f64c0a"]
# 生成したハッシュ化された方のtokenを適当なユーザーに保存する(deviseのrecoverableモジュールがincludeされたUserクラス)
User.first.update!(reset_password_token: "822bf2b8a9a2b1306caa533f025f5a58c8d252f4705207ff0e80da3750f64c0a")
# 平文の方のtokenをdigestメソッドの引数に渡す
token = Devise.token_generator.digest(User, :reset_password_token, "jgn9x4LH8aptzB2V7rBP")
=> "822bf2b8a9a2b1306caa533f025f5a58c8d252f4705207ff0e80da3750f64c0a"
User.to_adapter.find_first({ :reset_password_token => token })
=> #<User id: 1, ...
digestメソッドで平文の入力をハッシュ値に変換できることがわかりましたね。
次はもう既にコンソールで試してしまっていますが、 generate
メソッドを読んでみます。
generate
def generate(klass, column)
key = key_for(column)
loop do
raw = Devise.friendly_token
enc = OpenSSL::HMAC.hexdigest(@digest, key, raw)
break [raw, enc] unless klass.to_adapter.find_first({ column => enc })
end
end
klassには通常はdeviseのモジュールをincludeしているクラスを渡します。(Userクラスがdatabase_authenticatableなどをincludeしているならUserクラスを渡します)
columnは recoverableモジュールやdevise_invitableを使用しているなら:reset_password_token
や:invitation_token
を渡すことになると思います。
tokenの発行時に、異なるユーザーに同じtokenを発行してしまっては大事故になるので、loop文を使って、同じtokenを持っているユーザーがいないことが保証されるまでloopさせて、いなければ平文のtokenとハッシュ化したtokenを配列に詰めて返していますね。
余談
ふと思ったんですが、tokenがぶつかる可能性ってどれくらいあるんでしょうか。
ChatGPTに聞いてみました。
SHA256は、256ビット(32バイト)のハッシュ値を生成します。これは約 1.1579209 ×10 77 種類の異なる可能なハッシュ値を意味します。これは非常に大きな数で、理論上の重複の可能性は極めて低いです。
数字が膨大すぎるので、人が隕石に当たる可能性と比較してもらいました。
人が隕石(流星体)に当たる確率と、SHA256ハッシュ関数が特定のハッシュ値を生成する確率を比較すると、隕石に当たる確率の方がはるかに高いです。
一方で、人が隕石に当たる確率は、さまざまな推定がありますが、一般的には非常に低いとされています。例えば、NASAの推定によると、人が一生の間に隕石に当たる確率は約1/1,600,000(160万分の1)とされています。これは非常に低い確率ですが、SHA256ハッシュ関数が特定のハッシュ値を生成する確率に比べればはるかに高い確率です。
1日に1億回tokenを発行して、それを1年間継続しても、tokenがぶつかる可能性は事実上0に近いとのことでした。
最後に key_for
メソッドを読んでみましょう。
key_for
def key_for(column)
@key_generator.generate_key("Devise #{column}")
end
initialize時に生成された @key_generator
変数のgenerate_key
メソッドを呼んでいるので、 ActiveSupport::CachingKeyGenerator
のgenerate_key
メソッドを呼んでいるものと思います。
コンソールで試してみます。
key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key))
key_generator.generate_key("Devise #{:reset_password_token}")
=> "\x81&\xA3a\xB9\xAF$\x85\x86\xB8K\x9D\x7F\x86\x1Dw\xE9\xD4\xE5\x03\xB7S\xAB\xE29`~LH\xA8}\x89\x96\x19\x0E=\xF7\xAB\x96\x8F\xA3+\xB43(\xCD\xD5\x92\xE5\xF8\x10\x0E\xEF\xA0b\xAA\xAB\xF6\xB3\xB8\xE6z\x9A1"
digest = "SHA256"
raw = Devise.friendly_token
=> "wimvP14Lka9dKh2Xu-pz"
OpenSSL::HMAC.hexdigest(digest, key, raw)
=> "76d71f4183f4219d112ea3fdf56337a45e6967284a4978ddc08907785af9984a"
OpenSSL::HMAC.hexdigest
の引数としてkeyが利用されているので、データの整合性を取ったりセキュリティ強化に使われているようですね。
これで一通りtoken_generatorの挙動を把握できました。
なぜ token_generator を題材に選んだのか?
空行を含めても32行で、締切に追われている身としては解読にそれほど時間をかけなくてもいいためです
直近の業務で token_generator の挙動について調べた上で機能を実装する機会があり、自分含め一緒に働くメンバーへの知見の共有を行うために題材として選んでいます。
あとがき
32行ちょっとのコードでしたが、読むには前提知識がかなり必要で、思ったよりも疲れました、、、
ここ最近は既存機能の拡張だったり新規機能の作成にリソースの大部分を割かれており、どっぷり業務ドメインに浸かる毎日だったので、アドカレの開催で隠蔽されたロジックに向き合う時間を強制的に作れたのがよかったです。
OSSのドキュメントをじっくり読んだりコードを読むのはエンジニアとしての地力を上げるのに最適だと思っているので、忙しい中でも時間を作れるようにしていきたい、、、