パスワードのハッシュ、入れるたびに結果が違うのに、なぜログインは検証できるのか?
同じパスワードを bcrypt でハッシュ化すると、実行するたびに違う文字列が出てくる。それなのにログイン画面では同じパスワードをちゃんと「正しい」と判定できる。ハッシュ化は同じ入力から同じ出力が出るはずではなかったか。ここで何が起きているのか説明できるだろうか。
- パスワードは暗号化ではなく一方向ハッシュ関数で保存する。ログイン時は復号せず、入力を再ハッシュして格納値と比較する。
bcryptやArgon2の出力にはランダムなソルトとコスト係数が埋め込まれているため、同じパスワードでも毎回別の文字列になり、かつ検証はできる。- コスト係数は「1 回のハッシュ計算にどれだけ時間をかけるか」のツマミで、これを上げると正規のログインも総当たり攻撃も同じ比率で遅くなる。
なぜ SHA-256 で十分ではないのか
ハッシュ関数とは、任意の入力から固定長の出力を作る一方向の関数で、出力から入力を逆算できないことが設計上の前提になっている。「一方向」という性質だけを見ると、パスワードの保存には SHA-256 のような汎用ハッシュ関数で十分に思える。実際、平文で保存するよりは圧倒的にましだ。
しかし汎用ハッシュ関数は「大量のデータを高速にダイジェスト化する」ことを目的に設計されている。この速さが、パスワード保存では弱点になる。攻撃者はデータベースが漏洩した後、辞書やよくあるパスワードの候補を大量に用意し、それぞれをハッシュ化して漏洩したハッシュ値と突き合わせる総当たり(ブルートフォース)を行う。GPU を使った専用のクラッカーは汎用ハッシュ関数に対して 1 秒間に数十億回という規模の候補を試せるため、高速であること自体が攻撃者に有利に働く。
そこで生まれたのが bcrypt や Argon2 のような、意図的に低速・高コストに設計されたパスワード専用ハッシュ関数だ。正規のログイン処理では 1 回しか計算しないので数十〜数百ミリ秒の遅さは気にならないが、攻撃者が候補を億単位で試そうとすると、その遅さがそのままコストに跳ね返る。
ハッシュ化は、卵を割って混ぜてスクランブルエッグを作るような処理だ。できあがったスクランブルエッグから元の卵の形を復元することはできない(不可逆)。しかし「本当に同じ材料・同じ手順で作ったものか」を確認したければ、もう一度同じ材料で同じ手順でスクランブルエッグを作り、見た目を比べればよい。ログイン時にサーバーがやっているのはまさにこれで、保存された完成品を分解して元の卵に戻すのではなく、入力されたパスワードで再びスクランブルエッグを作り直し、保存済みの完成品と比較している。
ソルトが「同じパスワードでも毎回違う結果」を作る
ソルトとは、ハッシュ化の直前に入力へ付け足すランダムな値のことだ。パスワードそのものだけをハッシュ化すると、同じパスワードを使う 2 人のユーザーは同じハッシュ値を持つことになり、攻撃者は事前に大量のパスワード候補をハッシュ化して答え合わせ表(レインボーテーブル)を作り、漏洩したハッシュ値を一括で引き当てられてしまう。ソルトを混ぜると、同じパスワードでもユーザーごとに異なるハッシュ値になるため、この事前計算の使い回しが効かなくなる。
bcrypt の場合、ハッシュ化のたびに新しいランダムなソルトが生成され、出力される文字列そのものの中に埋め込まれる。つまりソルトを別のカラムに分けて保存する必要はなく、ハッシュ文字列 1 本だけを保存すればよい。
┌─ bcryptハッシュの中身 ──────┐ │$2b$12$N9qo8uLOickgx2ZMRZoMy │ │ eIjZAgcfl7p92ldGxad68LJ │ │ │ │ $2b → アルゴリズム版 │ │ $12 → コスト係数 (2^12回) │ │ 次の22文字 → ソルト(128bit) │ │ 残り31文字 → ハッシュ本体 │ └─────────────────────────────┘
検証時、サーバーはこの文字列からアルゴリズム版・コスト係数・ソルトを読み取り、ログインフォームに入力されたパスワードとそのソルトを使って再度ハッシュを計算し、残りのハッシュ本体部分と一致するかを比較する。ソルト自体は秘密にする必要がなく、公開されても安全なように設計されている。安全性を支えているのはソルトの秘匿ではなく、ユーザーごとに違う値である点と、後述するコスト係数の高さだ。
コスト係数とメモリハードネス
bcrypt のコスト係数(work factor)は「ハッシュ計算を何回繰り返すか」を指数で指定する値で、コスト n は内部で 2^n 回の反復に対応する。OWASP の Password Storage Cheat Sheet は、bcrypt のコスト係数について「検証サーバーの性能が許す限り大きくすべきで、最低でも 10」としている。コストを 1 上げるごとに計算量は倍になるため、コスト 10 が 1,024 回の反復なら、コスト 12 は 4,096 回、コスト 14 は 16,384 回に増える。コストを上げなければ、GPU クラスタを持つ攻撃者にとって総当たりの単価が下がったままになる。
bcrypt には設計上の制約もある。入力パスワードの扱える長さが 72 バイトまでという上限があり、OWASP はこれを踏まえてアプリ側でも 72 バイトを超える長さのパスワードを弾くことを推奨している。72 バイトを超えて入力しても静かに切り詰められて検証されるだけで、エラーにはならない実装があるためだ。
より新しい Argon2id は反復回数(時間コスト)に加えて使用メモリ量も設定できる、メモリハードな設計になっている。GPU は演算コアを大量に並べるのは得意でもメモリ帯域は共有制約になりやすいため、計算量だけでなくメモリ使用量でも攻撃者を制約する狙いがある。OWASP はパラメータ例として、メモリ 19 MiB・反復 2 回・並列度 1 という組み合わせを最小推奨値の一つとして挙げている。Argon2 の設計者側の RFC 9106 は、メモリを 2 GiB 使える環境向けに反復 1 回・並列度 4、メモリが限られる環境向けに 64 MiB・反復 3 回・並列度 4 という 2 系統の推奨パラメータを示している。どちらの目安を採用するかは、サーバーに割けるメモリと 1 ログインあたり許容できる待ち時間のトレードオフになる。
実装が「複合」ではなく「再計算」で検証する理由
ここまでの内容は「暗号化ではなくハッシュ化」という一言に集約できるが、この違いを正確に説明できる人は意外と少ない。暗号化は鍵を使って元のデータに戻せる(復号できる)ことが前提の処理で、鍵が漏れれば平文が読める。一方ハッシュ関数には復号処理という概念自体が存在しない。パスワードのハッシュ値がデータベースごと漏洩しても、そこから直接パスワードの文字列が読み取れるわけではなく、攻撃者にできるのは候補パスワードを片っ端からハッシュ化して一致を探す総当たりだけになる。
もう 1 点、実装で見落とされがちなのが比較方法だ。ハッシュ値どうしの比較を文字列の先頭から順に比較して最初に食い違った時点で false を返す実装だと、一致した文字数に応じてわずかに応答時間が変わり、これを積み重ねて計測することでハッシュ値を 1 バイトずつ推測されるタイミング攻撃の余地が生まれる。bcrypt や Argon2 の標準ライブラリの検証関数は、この時間差が生じない定数時間比較を内部で行うよう実装されているため、自前でバイト列を == 比較するコードに置き換えてはならない。
多くの Web フレームワークは、この一連の処理をライブラリの奥に隠している。Rails の has_secure_password は bcrypt-ruby を、Node.js の bcrypt / bcryptjs パッケージも同じ bcrypt アルゴリズムを、Go は標準ライブラリではなく golang.org/x/crypto/bcrypt をよく使う。どれも「ソルト生成」「コスト係数の適用」「文字列への埋め込み」「定数時間比較」を全部やってくれるので、自作の SHA-256+自前ソルトのコードに書き換える判断はまず正当化できない。触るとすれば、初期設定されたコスト係数がサーバーの応答性能に対して重すぎたり軽すぎたりしないかの調整が主な出番になる。
macOS 標準の htpasswd(Apache 付属)で同じパスワードを 2 回ハッシュ化し、毎回結果が変わることと、コスト係数がその中に埋め込まれていることを確かめる。
htpasswd -nbBC 12 alice 'CorrectHorseBatteryStaple' htpasswd -nbBC 12 alice 'CorrectHorseBatteryStaple'
同じユーザー名・同じパスワード・同じコスト係数 12 を指定しているのに、出力される alice:$2y$12$... の $2y$12$ の直後(ソルト部分)が毎回変わることが確認できる。-C の数字を 4 や 14 に変えて再実行すると、体感できるほど生成にかかる時間が変わることも観察できる。
- ハッシュ化と暗号化は同じもので、鍵があれば元のパスワードに戻せる — ハッシュ関数に復号処理は存在しない。データベースが漏れても、そこから直接パスワードが読み取れるわけではない。
- SHA-256 のような強力なハッシュ関数を使えばパスワード保存は安全 — SHA-256 は高速であること自体が総当たり耐性を弱める。パスワード保存には意図的に低速な bcrypt / Argon2 のような専用関数を使う必要がある。
- ソルトは秘密情報だから別テーブルや環境変数で厳重に管理すべき — ソルトはハッシュ文字列の中にそのまま平文で埋め込まれる公開情報として設計されている。役割は秘匿ではなく、ユーザーごとに結果を変えてレインボーテーブルを無効化することだ。
- ハッシュ関数
- 任意長の入力から固定長の出力を作る一方向関数。出力から入力を逆算できないことが前提。
- ソルト
- ハッシュ化前に入力へ付け足すランダムな値。同じ入力でも毎回異なる出力にし、事前計算による総当たりを無効化する。
- コスト係数(work factor)
- ハッシュ計算の反復回数を指数的に指定する値。上げるほど計算コストが増え、総当たりの単価も上がる。
- レインボーテーブル
- 大量のパスワード候補とそのハッシュ値を事前計算した対応表。ソルトなしのハッシュを一括で引き当てるために使われる。
- メモリハード関数
- 計算時間だけでなくメモリ使用量も要求するように設計された関数。GPU による並列計算での高速化を制限する狙いがある。
- 定数時間比較
- 入力の内容にかかわらず常に同じ処理時間で結果を返す比較方法。応答時間の差から情報を推測するタイミング攻撃を防ぐ。
- OWASP Password Storage Cheat Sheet — bcrypt のコスト係数最低値や Argon2id の推奨パラメータなど、実装時に迷う数値の最新の公式指針。
- RFC 9106: Argon2 Memory-Hard Function for Password Hashing — Argon2 設計者自身による、メモリ量・反復回数・並列度の推奨パラメータ 2 系統の一次情報。
- NIST SP 800-63B: Digital Identity Guidelines — 米国政府の認証ガイドライン本体。ソルトの最小長やコスト係数を「検証サーバーの性能が許す限り大きく」とする要件の一次情報。