PHP でパスワードを保存するなら Blowfish でハッシュしよう
今週末に劇場公開される『平成ライダー対昭和ライダー 仮面ライダー大戦 feat.スーパー戦隊』に草加雅人が登場するときいて期待よりも不安に駆られている kagata です。仮面ライダーが30人も出てくるお祭り映画で、彼の腐りきった性根もとい、複雑に屈折したキャラクターを描ききることができるのでしょうか。いきなりいい奴になってたらどうしよう…そう、たとえば劇場版ジャイアンのように…。
さて、今回は PHP とパスワードハッシュのお話です。パスワードを平文で管理していた時代は遠く過ぎ去りました(と思いたい)。またパスワードハッシュのアルゴリズムも、かつてよく使われた MD5 や SHA1 よりも安全とされるものが登場しています。そんな中、今現在のベストプラクティスはどうなっているのか、PHP マニュアルの公式(?)見解をひもといてみました。
PHP マニュアルの見解
PHP マニュアルにはその名もずばり『安全なパスワードハッシュ』という記事があります。ここで、パスワードをハッシュするアルゴリズムとして推奨されているのが、今回ご紹介する Blowfishです。
パスワードをハッシュするときのおすすめのアルゴリズムは Blowfish です。 パスワードハッシュ API でも、このアルゴリズムをデフォルトで使っています。 というのも、このアルゴリズムは MD5 や SHA1 と比較して計算コストが高いにもかかわらず、スケーラブルだからです。
この記事ではほかにも、パスワードを平文のままでなくハッシュして保存すべき理由、MD5 や SHA1 など古くからパスワードのハッシュに使われてきたアルゴリズムを推奨しない理由、ソルトの重要性などについても語られています。PHP でパスワードを扱うシステムを作るなら必読ですね。
では、PHP で実際に Blowfish ハッシュを生成してみましょう。PHP のバージョンにより、主に2つの方法があります。
方法1. crypt() を使って Blowfish ハッシュを得る
PHP5.4 以前の枯れたバージョンでも使えるのが、関数 crypt() を使う方法です。 Blowfish ハッシュを得るために使いやすいよう、ラップした関数を作ってみました。
/**
* blowfish 与えられた文字列とコストから Blowfish ハッシュを返す
*
* @param $raw 元の文字列(平文パスワード)
* @param $cost コスト(4以上31以下の整数)
* @return string 引数で指定した文字列の Blowfish ハッシュ
*/
function blowfish($raw, $cost = 4) {
// Blowfishのソルトに使用できる文字種
$chars = array_merge(range('a', 'z'), range('A', 'Z'), array('.', '/'));
// ソルトを生成(上記文字種からなるランダムな22文字)
$salt = '';
for ($i = 0; $i < 22; $i++) {
$salt .= $chars[mt_rand(0, count($chars) - 1)];
}
// コストの前処理
$costInt = intval($cost);
if ($costInt < 4) {
$costInt = 4;
} elseif ($costInt > 31) {
$costInt = 31;
}
// 指定されたコストで Blowfish ハッシュを得る
return crypt($raw, '$2y$' . sprintf('%02d', $costInt) . '$' . $salt);
}
ソルトに使える文字は、大文字小文字アルファベットと '/' および '.' の54種類。ここからランダムに22文字を拾ってソルトとします。ソルトは同じものを使いまわすよりも、ハッシュごとに毎回異なるものを使った方が安全です。crypt() なら、得られるハッシュにソルト文字列がそのままくっついてくるので、毎回異なるソルトを使っても管理に手間がかかりません。
コストの値は4から31までの整数を指定します。この値を大きくすると、ハッシュの計算時間が長くなります。計算時間をかけるほど総当たりによるパスワード破りには強くなりますが、その反面サーバの負荷が大きくなり DoS 攻撃には弱くなってしまいます。バランスを見て設定しましょう。
最後に、crypt() で使っている '2y' という文字列はパスワードハッシュのアルゴリズムを指定するもので '2a' '2x' '2y' の3通りが Blowfish に割り当てられています。 PHP5.3.7 以前では '2a' を指定しなければなりませんが、この設定値ではセキュリティに問題があるそうです。万全を期すなら、 PHP5.3.8 以上の環境で '2y' を指定しましょう。
得られたパスワードハッシュを使って、入力されたパスワードを検証するには次のようにします。
<?php
// 平文パスワード
$raw = 'password';
// パスワードの Blowfish ハッシュをとる
$hashed = blowfish($raw, 10);
// パスワードを検証する
if (crypt($str, $hash) === $hash) {
echo '正しいパスワードです';
} else {
echo 'パスワードが間違っています';
}
ただし、この方法ではタイミング攻撃に対して防御策がとられていません。'===' による比較にかかる時間が入力値によって異なることから、パスワードを解析するヒントを攻撃者に与えてしまう可能性があります。より万全を期すなら、次に紹介するパスワードハッシュ API を使いましょう。
方法2. パスワードハッシュ API を使う(PHP5.5 以降)
PHP5.5 から、汎用的なハッシュとは別にパスワードのハッシュを安全に行えるパスワードハッシュ API というものが準備されています。これを使うと、 crypt() だけでは対策できなかったタイミング攻撃にも安全なパスワード管理ができるようになります。
先に紹介した crypt() 関数によるコードと同じことを、パスワードハッシュ API でやってみます。
<?php
// 平文パスワード
$raw = 'password';
// パスワードの Blowfish ハッシュをとる
$hashed = password_hash($raw, PASSWORD_DEFAULT, array('cost' => 10));
// パスワードを検証する
if (password_verify($raw, $hashed)) {
echo '正しいパスワードです';
} else {
echo 'パスワードが間違っています';
}
演算子 '===' でなく専用の関数 password_verify() で入力されたパスワードを検証することで、タイミング攻撃にも強い認証システムを作ることができます。
そして、ハッシュを生成する関数 password_hash() にもいろいろな利点があります。
ハッシュの形式は crypt() と互換
crypt() が生成するハッシュと同じ書式で、ハッシュアルゴリズムやソルト等の情報を自身に保持したハッシュを生成します。crypt() を使ったシステムからの移行もらくらく。
ランダムなソルトを自分で生成してくれる
第3引数の配列でソルトを与えることもできるようですが、何も指定しなければ内部でよしなにランダムなソルトを用意してくれるようになっています。もちろん、ソルトの管理についてユーザーが心配しなくてよいのは crypt() と同じです。
PHP のバージョンアップとともにハッシュアルゴリズムがアップデートされる
第2引数はハッシュアルゴリズムを示す整数値です。定数 'PASSWORD_DEFAULT' を指定すると、PHP5.5 では Blowfish が使われます。
さらに、今後 Blowfish より安全なハッシュアルゴリズムが PHP で使えるようになると、 'PASSWORD_DEFAULT' の値が変更されて新しいアルゴリズムを指すようになる予定だそうです。PHP をバージョンアップするだけで、システムがよりセキュアになっていくわけですね。これはすばらしい。
ハッシュアルゴリズムが知らない間に変わってしまうと、それまでのハッシュが使えないようになってしまうんじゃないか?と心配になりますが、パスワードハッシュ API はハッシュを見て「どのハッシュアルゴリズムで検証すればよいか」を自分で判定してくれるので心配はご無用です。なお、特に Blowfish を使い続けたい!ということであれば、第2引数に定数 'PASSWORD_BCRYPT' を指定すれば OK です。
余談: PEAR のパッケージ Crypt_Blowfish について
ちなみに、ここまで紹介した方法のほかに Crypt_Blowfish という PEAR パッケージもあるようです。が、長くメンテナンスされていないようですね。今となってはこれを利用する機会もないでしょう。
まとめ
- パスワードは Blowfish でハッシュしよう
- ソルトには毎回異なる値を使おう。crypt() や パスワードハッシュ API なら、ソルトの管理もらくらく
- PHP5.5 以上ならパスワードハッシュ API が便利かつ安全!