deviseはパスワードをどのように安全に保管しているか?
3行まとめ
- Deviseはソルトとハッシュ値をDBに保存している。
- pepper(Secret Salt)値はデフォルトでは使われていない。安全性を上げたければ追加したほうが良い。
- Deviseを使っていればとりあえず安全そう。
きっかけ
pictBLandとpictSQUAREに対する不正アクセスがあり、パスワードがソルトなしのMD5ハッシュで保存されていたことが話題になっています。
Ruby on Railsで広く使われているDeviseはどのようにパスワードを安全に保管しているのかを確認してみます。
背景
Deviseは、1億6000万以上のダウンロードを誇るRails向けの認証ミドルウェアです。
暗号化操作のほとんどが抽象化されているため裏で何が起こっているかを知らないで使っている場合がほとんどです。
Deviseにおいて、パスワードが保存されているストレージのカラム名は encrypted_password
です。
このカラムには、以下のような文字列が保管されていて、この文字列は一体何を意味しているのか、どの用に使われているのかを説明します。
$2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu
DeviseはBcryptを使用して情報を安全に保存します。
Bcryptのサイトには、「OpenBSD bcrypt() パスワード ハッシュ アルゴリズムを使用しているため、ユーザーのパスワードの安全なハッシュを簡単に保存できる」と記載されています。
しかし、このハッシュとは一体何なのでしょうか?どのように機能し、保存されたパスワードをどのように安全性を保つのでしょうか?
Deviseにおけるパスワード保存の流れ
データベースに保存されているハッシュから暗号化と復号化のプロセスまでを保存されている文字列から逆に辿って検証してみます。
まず、データベースに保存されている値を取得します。
irb> User.first.encrypted_password
=> "$2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu"
この文字列、$2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu
は実際にはいくつかのコンポーネントで構成されています。
- Scheme (
2a
) - ハッシュの生成に使用されるbcrypt()
アルゴリズムのバージョン - Cost (
12
) - ハッシュの作成に使用されるコスト係数 - Salt (
GfOja7i1byocYP7XuANk9O
) - パスワードと組み合わせると一意になるランダムな文字列 (22文字) - Checksum (
qyiE8KJPzG439mk0kZKB1rmggsxOHFu
) - 保存されている実際のハッシュ部分 (31文字)
このフォーマットはMCF(Modular Crypt Format)です。
UNIXの/etc/shadowなどにも使われているメジャーなフォーマットです。
最後の3つのパラメーターを調べてみましょう。
- Deviseを使用する場合、値はストレッチCostと呼ばれるクラス変数によって設定され、デフォルト値は
12
(2019年に11から12になりました)です。パスワードをハッシュする回数を指定します。 - Saltは、元のパスワードと組み合わせるために使用されるランダムな文字列です。これは、同じパスワードが暗号化されて保存されるときに異なる値になる原因です。(なぜそれが重要なのか、またレインボーテーブル攻撃とは何なのかについては、こちらを参照してください。)
- Checksumは、Saltと結合された後に実際に生成されたパスワードのハッシュです。
ユーザーがアプリに登録するときは、パスワードを設定する必要があります。
このパスワードがデータベースに保存される前に、前述のコスト要因を考慮して、BCrypt::Engine.generate_salt(cost)
によってランダムなSaltが生成されます。
(注: pepperクラス変数値が設定されている場合、Salt処理を行う前にその値がパスワードに追加されます。)
そのSalt (例: $2a$12$GfOja7i1byocYP7XuANk9O
)を使用して、生成されたSaltとユーザーが入力したパスワードを使用して保存される最終ハッシュを計算します。BCrypt::Engine.hash_secret(パスワード, Salt)
その結果(例: $2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu
)がデータベースの encrypted_password
カラムに保存されます。
BCrypt::Password.create
が BCrypt::Engine.generate_salt(cost)
によって呼ばれます。しかし、このハッシュが不可逆であり、Saltがによる呼び出しでランダムに生成される場合、それをユーザーのサインイン時にどのようにつかわれるのでしょうか?
そこで、これらのさまざまなハッシュ コンポーネントが役立ちます。ユーザーがサインインするために指定したメールアドレスに一致するレコードが見つかった後、暗号化されたパスワード(=encrypted_password
の値)が取得され、上記のように5つのコンポーネント (Bcrypt version
, Cost
, Salt
, Checksum
) に分割されます。
この最初の準備が完了したら、次の順番で処理が行われます。
- 入力されたパスワードを取得します(
ThisIsWeakPassword
) - 保存されているパスワードのSaltを取得します(
$2a$12$GfOja7i1byocYP7XuANk9O
) - 同じBcryptバージョンとコスト係数を使用して、パスワードとSaltからハッシュを生成します(
BCrypt::Engine.hash_secret('ThisIsWeakPassword', “$2a$12$GfOja7i1byocYP7XuANk9O”)
) - 保存されているハッシュがステップ3で計算されたものと同じかどうかを検証します(
$2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu
)
これにより、Deviseはパスワードを安全に保存し、データベースが侵害された場合でもさまざまな攻撃からユーザーを保護します。
上記の一連の流れを rails console
で実行すると以下のようになります。
irb(main):042:0> user = User.first
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, email: "foo@example.com", created_at: "2023-08-17 11:27:10.671417000 +0000", updated_at: "2023-08-17 11:32:20.492265000 +0000">
irb(main):043:0> salt = user.encrypted_password[0, 29]
=> "$2a$12$GfOja7i1byocYP7XuANk9O"
irb(main):044:0> BCrypt::Engine.hash_secret('ThisIsWeakPassword', salt) == user.encrypted_password
=> true
pepperに関して
Deviseではデフォルトでpepperは使われていません。pepperとは、Secret Saltとも呼ばれます。
Saltはレコードごとに生成されるランダムな数値ですが、pepperシステム固有の値で、データベースとは別のストレージに保管するべき秘密の文字列です。
使い方は、ユーザが入力した文字列に対してpepperを連結して利用します。それにより、DBの値が第三者に流出してしまった場合でも、ハッシュによる総当たりを防ぐための仕組みで、より安全なパスワード運用を行えます。
Deviseではpepperの部分はデフォルトではコメントアウトされているので利用したい場合はコメントインをする必要があります。
DeviseのREADMEでもpepperに関しては殆ど触れられていません。deviseのセットアップドキュメントを見ても殆ど触れられていることはありません。
initializerの設定ファイルを眺めていないと気ずけ無いですが設定しておくことをおすすめします。
Deviseのデフォルトでは、 SecureRandom.hex(64)
で生成された値が入ります。
値の例はこのようになります。59ef98ac93a05c22d065dab431e6fc23a8110577c0a18c7e4ac603cdd7f4d2c327e6f6350ef0721de280caadc348c8a3cd04199708e546775627067a2c5d9951
Strechの値の運用
bcryptの計算難易度を調整するパラメータが4年前に 11
から 12
に変更されました。
4年より前に運用しだして、設定ファイルを更新していない場合は 11
になっているので 12
以上に変更することをおすすめします。
ベンチマーク
このStrechの値に対して計算コストは指数的に増加します。それにより、1回のパスワード検証コストを多くしてクラックされづらいようにします。
本当に指数的に計算時間が増加するかを検証してみます。
まずは以下のように検証用のコードを書いてみます。20回のハッシュ計算を行います。costの値は運用する可能性がある5から15を利用しています。
require 'bcrypt'
require 'benchmark'
results = {}
(5..15).each do |cost|
results[i] = Benchmark.measure {
20.times do
salt = BCrypt::Engine.generate_salt(cost)
BCrypt::Engine.hash_secret('password', salt)
end
}.real
end
results.each do |key, value|
puts "Cost #{key} (20 iterations): #{value.round(5)} seconds"
end
上記のコードの結果は以下のようになります。
Cost 5 (20 iterations): 0.05674 seconds
Cost 6 (20 iterations): 0.07473 seconds
Cost 7 (20 iterations): 0.14307 seconds
Cost 8 (20 iterations): 0.28359 seconds
Cost 9 (20 iterations): 0.56139 seconds
Cost 10 (20 iterations): 1.12196 seconds
Cost 11 (20 iterations): 2.24226 seconds
Cost 12 (20 iterations): 4.47873 seconds
Cost 13 (20 iterations): 8.95266 seconds
Cost 14 (20 iterations): 18.34221 seconds
Cost 15 (20 iterations): 36.58495 seconds
38.0|
36.0| *
34.0|
32.0|
30.0|
28.0|
26.0|
24.0|
22.0|
20.0|
18.0| *
16.0|
14.0|
12.0|
10.0|
8.0| *
6.0|
4.0| *
2.0| * *
0.0+-*--*--*--*--*-------------------
5 6 7 8 9 10 11 12 13 14 15
例えば、costが 12
の場合は1回の計算に約0.22秒かかる事がわかり、そこそこ時間がかかります。
(測定環境は、Apple M1 Pro上でDocker上のmruby 3.0.3p157になります)
saltの算出時間は、
それに付随して、rspecなどの静的テストのときに1回の計算ごとに時間をかけていたらテストの実行時間が長くなってしまうのでDeviseの初期設定ではRails.envが test
の際にはcostを 1
で実行してテストの実行時間を短くしています。
config.stretches = Rails.env.test? ? 1 : 10
参考URL
- How Devise keeps your Rails app passwords safe
Discussion