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: "[email protected]", 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=> truepepperに関して
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 }.realend
results.each do |key, value| puts "Cost #{key} (20 iterations): #{value.round(5)} seconds"end上記のコードの結果は以下のようになります。
Cost 5 (20 iterations): 0.05674 secondsCost 6 (20 iterations): 0.07473 secondsCost 7 (20 iterations): 0.14307 secondsCost 8 (20 iterations): 0.28359 secondsCost 9 (20 iterations): 0.56139 secondsCost 10 (20 iterations): 1.12196 secondsCost 11 (20 iterations): 2.24226 secondsCost 12 (20 iterations): 4.47873 secondsCost 13 (20 iterations): 8.95266 secondsCost 14 (20 iterations): 18.34221 secondsCost 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の算出時間は、2×10⁻⁴ 秒程度なので無視できるくらい小さく、costの値に関係なくO(1)になります。
それに付随して、rspecなどの静的テストのときに1回の計算ごとに時間をかけていたらテストの実行時間が長くなってしまうのでDeviseの初期設定ではRails.envが test の際にはcostを 1 で実行してテストの実行時間を短くしています。
config.stretches = Rails.env.test? ? 1 : 10参考URL
- How Devise keeps your Rails app passwords safe
How Devise keeps your Rails app passwords safe freecodecamp.org By Tiago Alves Devise is an incredible authentication solution for Rails with more than 40 million downloads. However, since it abstracts most of the cryptographic operations, it’s not always easy to understand what’s happening behind the scenes. One...


