パスワード管理って大事ですよね。ただ、未だにどうするのが正解なのかってのをちゃんと調べたことがなかったのです。
そこで、以下のPHPのドキュメントを参考に、安全なパスワードハッシュについて学んでいきたいと思います。
- はじめに
- なぜ、パスワードをハッシュしないといけないか
- md5やsha1はパスワードハッシュに適してない
- パスワードのハッシュ方法
- ソルトとは
- 実際にパスワードをハッシュ化してみる
- パスワードの検証をしてみる
- costの選定方法
- さいごに
はじめに
この記事は、上記のドキュメントを元に、自分なりに学習して、まとめてみた記事になります。間違っていたりしたらコメントで指摘していただけると嬉しいです。
なぜ、パスワードをハッシュしないといけないか
パスワードのハッシュは、最も基本的なセキュリティ要件のひとつ。 ハッシュ化しないで格納したデータベースが攻撃を受けて盗まれたら大変。他サービスでアカウント+同じパスワードを使っていたら被害が広がる。
ハッシュ化することで、攻撃者のパスワード解析は難しくはなるが、ちょっとした保護だけではある。
md5やsha1はパスワードハッシュに適してない
MD5やSHA1、SHA256のようなハッシュアルゴリズムは、高速かつ効率的なハッシュ処理向けに作られたものなので、アルゴリズムの出力をブルートフォースで時間をかければ元の入力を簡単に取得できてしまう。
ブルートフォースとは
ブルートフォースは総当たり攻撃みたいなもの。最近はどんどんマシンスペックがあがり、攻撃する側にとってもより力ずくが有効になっている。
例えば、4桁のダイヤル式の南京錠を使っていたとしたら、10,000万通り試せば、必ずロックが解除できてしまいます。手で開けるのはちょっと大変かもしれませんが、勝手にクルクル回してくれる機械・マシンがあれば簡単ですね。
ちょうど一昨日くらいに放送された所さんのそこんトコロでも、開かずの金庫を全通り試す機械を使って開けようとしていたりもしてました。たぶんこれもブルートフォースみたいなものかしら?
ハッシュ後の値から、ハッシュの逆変換をしてしまう
以下のようなHash Toolkitというサイトでは、md5とかsha1などのハッシュ化した結果をデータベースに保存してしまおうと考えた方もいるみたいです。
この記事を書いているときには、約190億もの文字列のハッシュした値を保存しているようです。
md5やsha1、sha256はもちろん、いくつかのハッシュアルゴリズムの結果をDBに保存しておいて、そこから検索することで、一瞬でハッシュ元の値を求めてしまいます。もちろん、DBに保存されていない文字列は検索にヒットしません。ただ、DBに保存されている文字列はものすごい勢いで増えています。
パスワードのハッシュ方法
パスワードのハッシュ時に重要なのは、計算量とコスト。 計算コストが増えれば、ブルートフォースによる解析に時間がかかる。
こういうのって、攻撃側にどれだけ時間をかけさせるかってことですね。
PHPのネイティブのパスワードハッシュAPI
PHPには、ハッシュの計算やパスワードの検証を行う機能があるので、これを使いましょうと。
ここで用意されている、password_hash()やpassword_verify()を使用する。
crypt()を使っても良いのか?
パスワードのハッシュ化でcrypt関数を使う方法もある。
ただ、このページにも書いているが、
password_hash()
は、強力なハッシュを使い、強力なソルトを生成して、それを複数回自動的に適用します。password_hash()
はcrypt()
のシンプルなラッパーであり、既存のパスワードハッシュと互換性があります。password_hash()
を使うことを推奨します。
とのことなので、password_hash()
を使うのが良さそうです。
タイミング攻撃に対して安全な文字列比較
もし、crypt()を使いたい場合の注意として、タイミング攻撃に注意しないといけないみたいです。==
演算子や===
演算子、strcmp()
などの文字列比較処理の処理時間は一定ではないらしく、hash_equals()
を使いましょうとのことらしいです。
使い方は、以下のように、文字列比較時に使用します。
<?php $hashed_password = crypt('mypassword'); // saltを自動的に生成させます /* 異なったハッシュアルゴリズムが使用された際の問題を避けるために crypt()の結果全体をパスワード比較用のsaltとして渡す必要があります。 (上記のように標準DESに基づくパスワードハッシュは2文字のsaltを使用します が、MD5に基づくハッシュは12文字のsaltを使用します) */ if (hash_equals($hashed_password, crypt($user_input, $hashed_password))) { echo "Password verified!"; }
参考:https://www.php.net/manual/ja/function.crypt.php
こういうのを考えるのが大変なので、やはりpassword_verify()
を使うのが楽そうですね。
password_hash()のおすすめアルゴリズム
ドキュメントを読む限り、
- MD5やSHA1と比べても計算コストが高いにもかかわらず、スケーラブル
とのことで、パスワードのハッシュするアルゴリズムは、Blowfishがおすすめみたいです。パスワードハッシュAPIでも、このアルゴリズムをデフォルトで使っているみたいです。
ソルトとは
暗号理論におけるソルトは、ハッシュ処理の際に追加するデータ。これをつけるだけで、ハッシュをクラックするのが劇的に難しくなるらしい。
password_hash()
でソルトを指定することもできるが、指定しないとランダムで生成してくれるので、一般的には指定しないのがお手軽で安全なアプローチとのことらしいです。
ただ、password_hash()のドキュメントを確認してみると、
このソルト・オプションは、PHP 7.0.0 で非推奨になりました。 このオプションは指定せずに、デフォルトで生成されるソルトを使うことを推奨します。
とのことなので、PHP7以降はソルトは指定しない方が良さそうですね。
ソルトの保存方法
password_hash()
やcrypt()
を使った場合の戻り値のパスワードハッシュには、ソルトも含まれるので、そのままの形式でデータベースに格納してしまって良いとのこと。
利用したハッシュ関数の情報がそこに含まれているので、password_verify()
やcrypt()
にそのまま渡してあげれば、検証できるとのこと。
実際にパスワードをハッシュ化してみる
では、早速password_hash()を利用してハッシュ化してみましょう。
<?php echo password_hash('password', PASSWORD_BCRYPT);
わかりやすくpassword
というパスワードにしてみました。
これを実行してみると、
$ php hash.php $2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS
この$2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS
をそのままデータベースに保存すれば良いということですね。
password_hash()の結果に含まれる情報
では、上記の$2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS
を詳しくみてみる。
ドキュメントを見た感じだと、以下のように分けられるみたいです。
$2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS
このように分けられるみたい。
- $2y$:Algorithm
- 10$:アルゴリズムのオプション(cost等)
- xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.:ソルト
- NfKdm1pn2l3OAUcPch6wS:ハッシュ化されたパスワード
パスワードの検証をしてみる
次にpassword_verify()を使用して、パスワードの検証をしてみます。
<?php $hash = '$2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS'; echo "pass123:\n"; if (password_verify('pass123', $hash)) { echo 'Password is valid!'; } else { echo 'Invalid password.'; } echo "\n"; echo "password:\n"; if (password_verify('password', $hash)) { echo 'Password is valid!'; } else { echo 'Invalid password.'; }
先程生成した、$2y$10$xiRMawh7tQEv8DQJ3CbRKu5swrSW6/x.NfKdm1pn2l3OAUcPch6wS
を利用しています。データベースから取得した結果を$hash
に格納したとしてパスワード検証しています。
$ php verify.php pass123: Invalid password. password: Password is valid!
このように、ちゃんとパスワードの検証をすることができました。
costの選定方法
password_hashのオプションで渡すcostについてです。デフォルトで10が設定されるようになっていますが、ここの値はなるべく大きな値にしたほうが良いです。 ただ、大きくすると処理時間がかかってしまうので、大きすぎても駄目です。
そこで、ドキュメントに書かれている、適切なコストを探す例をここでは確認したいと思います。
- このコードは、サーバーをベンチマークして、どの程度のコストに耐えられるかを判断します。
- サーバーに負荷をかけすぎない範囲で、できるだけ高めのコストを設定したいものです。
- 基準として 8 から 10 程度からはじめ、サーバーが十分に高速なら、できるだけ上げていきましょう。
- 以下のコードでは、ストレッチングの時間を 50 ミリ秒以内にすることを狙っています。
- 対話形式のログインを扱う際の許容時間としては、このあたりが妥当なところでしょう。
と書かれているように、サーバーにどのくらいの時間・負荷をかけるかを予め想定しておいて、それに見合ったコストを算出しましょうという考え方ですね。このサンプルコードでは、50msecをターゲットとしているようです。
<?php $timeTarget = 0.05; // 50 ミリ秒 $cost = 8; do { $cost++; $start = microtime(true); password_hash("test", PASSWORD_BCRYPT, ["cost" => $cost]); $end = microtime(true); } while (($end - $start) < $timeTarget); echo "Appropriate Cost Found: " . $cost;
これをmac上のdockerで立ち上げたコンテナ内で実行してみました。 せっかくなので、ターゲットを100msecにして実行してみます。
php -v PHP 8.0.0 (cli) (built: Dec 11 2020 07:41:29) ( NTS ) Copyright (c) The PHP Group Zend Engine v4.0.0-dev, Copyright (c) Zend Technologies with Xdebug v3.0.1, Copyright (c) 2002-2020, by Derick Rethans
PHPのバージョンは8.0.0で実行してみました。
$ php bench.php Appropriate Cost Found: 11
コストは11と出てきました。
ということで、このmac上だとcost=11をオプションに含めて行うと良さそうみたいですね(100msecとした場合)
<?php $options = [ 'cost' => 11, ]; echo password_hash("password", PASSWORD_BCRYPT, $options);
ということで、cost=11
をオプションに追加して実行してみます。
$ php hash_benched.php $2y$11$Ott.IGp9ZJmcJWari6J/duhoCtsGnBnsSyti51OBDWMIH/l5v4aH2
たしかに、出力結果に11$
と書かれていますね。なるほど!
さいごに
今回はPHPのドキュメントにかかれている安全なパスワードハッシュを参考に勉強させていただきました。
最初にも書かせていただきましたが、勉強しながら書いた記事なので、間違っているところもあるかもです。読む方は注意しながら読んでいただければと思います。