読者です 読者をやめる 読者になる 読者になる

mikanmarusanのブログ

テクノロジーとかダイビングとか

PHP5.5.0 から導入されたパスワードハッシュ関数を使ってみた

概要

パスワードのハッシュ
パスワードハッシュ API は crypt() を手軽に使えるようにしたラッパーで、 パスワードの作成や管理を安全な方法で行えます。

crypt() を利用すればパスワードハッシュの生成は可能なんだけど、経験上煩雑なコードになりやすい気がして(自分がPHPを書くのが下手なだけかもしれないけど)ちょっと勉強してみた。

環境構築

vagrantUbuntu(raring)を用意し、php5.5.7をLaunchpadのPersonal Package Archiveでインストール。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 13.04
Release:    13.04
Codename:   raring

$ php -v
PHP 5.5.7-1+sury.org~raring+1 (cli) (built: Dec 12 2013 21:50:03) 

API一覧

まずは一覧から。4つのメソッドが公開されているので順に説明していく。

  • password_hash : パスワードハッシュを作る
  • password_get_info : 指定したハッシュに関する情報を返す
  • password_verify : パスワードがハッシュにマッチするかどうかを調べる
  • password_needs_rehash : 指定したハッシュがオプションにマッチするかどうかを調べる

password_hash

まずは hash 生成関数、最初にしてメイン。

string password_hash ( string $password , integer $algo [, array $options ] )

第1引数 $password は生パスワード、第2引数 $algo はハッシュアルゴリズム定数、第3引数は $options アルゴリズムがサポートするオプションを入力する。PHP5.5では PASSWORD_DEFALUT と PASSWORD_BCRYPT が同じ値を指しており、共にBCRYPTが利用される模様。本当かな?と思って調べてみる。

# PASSWORD_DEFALUT と PASSWORD_BCRYPTの定数を覗いてみる
<?php
  echo "[constants]" . PHP_EOL;
  echo " PASSWORD_BCYRPT: "  . PASSWORD_BCRYPT  . PHP_EOL;
  echo " PASSWORD_DEFAULT: " . PASSWORD_DEFAULT . PHP_EOL;
# 実行結果
[constants]
 PASSWORD_BCYRPT: 1
 PASSWORD_DEFAULT: 1

ということで、2つのアルゴリズムを利用して(同じだけど)生のパスワードのハッシュを作ってみる。

<?php
    $raw_passwd = '3kanmarusan_Passw0rd';

    echo 'PASSWORD_DEFAULT' . PHP_EOL;
    $hashed_passwd = password_hash($raw_passwd,
                                   PASSWORD_DEFAULT);
    echo ' hash:' . $hashed_passwd . PHP_EOL;

    echo 'PASSWORD_BCRYPT(cost12)' . PHP_EOL;
    $hashed_passwd = password_hash($raw_passwd,
                                   PASSWORD_BCRYPT,
                                   ['cost' => 12]); // コストも変えてみる
    echo ' hash:' . $hashed_passwd . PHP_EOL;
# 実行結果
PASSWORD_DEFAULT
 hash:$2y$10$fAO/bg1Ti9.yBM3wC3FyJeIfrIql9dVFx/dhTDZO.FjSSjylRRCLK
PASSWORD_BCRYPT(cost12)
 hash:$2y$12$OciCNOfWegjXIgyNIoO/ZOPLt6wPLWcnYTYFwmAGoN6.2dG/4itZi

$2yで始まっているので、共にBCRYPTっぽい。その次の$10とか$12がコストを表すので PASSWORD_DEFAULT はcost10のBCRYPTということになる。

ちなみに、パスワードハッシュ生成方法そのものは、phpソースコードgithub.com からダウンロードして確認することができる。300MB超えのためワイヤレス環境だと厳しい。

git clone https://github.com/php/php-src.git
cd git-src
git checkout php-5.5.7
cd ext/standard
vim password.c

少々ソースを読んでみると、デフォルトのアルゴリズムとなる PASSWORD_BCRYPT は、

  • ハッシュ文字列の最初の7バイトでアルゴリズムとコストを表す
  • /dev/urandom を利用して乱数ビット列を作り、Base64 に変換した最初の22バイトをソルトデータとする。
  • BLOWFISH アルゴリズムを利用して、ソルト+ストレッチング内蔵の60バイトのパスワードハッシュを生成。
  • crypt内部の挙動としては、実際は最初の7バイトとソルトデータの22バイトを合わせた29文字をソルトとしてハッシュ計算する模様(この辺りよくわかっていない)

というロジックになっているようだ(ざっくり)。

PASSWORD_DEFAULT と PASSWORD_BCRYPT の使い分け

password_hash には下記のことが書かれている。

  • PASSWORD_DEFAULT - bcrypt アルゴリズムを使います (PHP 5.5.0 の時点でのデフォルトです)。 新しくてより強力なアルゴリズムPHP に追加されれば、 この定数もそれにあわせて変わっていきます。 そのため、これを指定したときの結果の長さは、変わる可能性があります。 したがって、結果をデータベースに格納するときにはカラム幅を 60 文字以上にできるようなカラムを使うことをお勧めします (255 文字くらいが適切でしょう)
  • PASSWORD_BCRYPT - CRYPT_BLOWFISH アルゴリズムを使ってハッシュを作ります。これは標準の crypt() 互換のハッシュで、識別子 "$2y$" を使った場合の結果を作ります。 その結果は、常に 60 文字の文字列になります。失敗した場合に FALSE を返します。

こう書かれると、メンテナンスコストも考えると PASSWORD_DEFAULT を選ぶのが怖い気もするけれども、マニュアルの下の方にアルゴリズムの更新/追加/デフォルト化のリリースルールが書かれている。

注意: この関数がサポートするアルゴリズムの更新 (あるいはデフォルトのアルゴリズムの変更) は、必ず次の手順にのっとって行われます。

  • 新しく追加されたアルゴリズムがデフォルトになるまでには、 少なくとも一度は PHP のフルリリースを経ること。 つまり、たとえば、新しいアルゴリズムが 5.5.5 で追加されたとすると、 そのアルゴリズムがデフォルトになるのは早くても 5.7 以降ということになります (5.6 は、最初のフルリリースだからです)。 しかし、もし別のアルゴリズムが 5.6.0 で追加されたとすると、 そのアルゴリズムも 5.7.0 の時点でデフォルトになる資格を得ます。
  • デフォルトを変更できるのはフルリリース (5.6.0 や 6.0.0 など) のときだけで、リビジョンリリースでは変更できない。 唯一の例外は、現在のデフォルトにセキュリティ上の致命的な欠陥が発覚した場合の緊急リリースです。

先週(2014/1/23)、PHP5.6.0alpha1がリリースされたけれども、

  • パスワードハッシュAPIには大きな変更が無さそうなのでデフォルトが変更されるのはもう少し時間がかかりそう
  • このまま行けばPHP5.6ではアルゴリズム変更は無いので、少なくとも次のフルリリースとなる(であろう)PHP5.7を経て、PHP5.8まで変更はないと期待できる
  • パスワードハッシュAPIアルゴリズム更新ルールが明確
  • (後述しますが)パスワードハッシュそのものにアルゴリズムやコスト、ソルトなどが含まれており、異なるハッシュアルゴリズムが混在していもパスワードの検証は出来る。

を考えると、PASSWORD_DEFAULT でもあまり問題にならないのではないかと思っている(個人の見解です)。

とはいえ、アルゴリズムはどんどん強固なものに更新していく必要もあり、それをサポートする後述の機能(password_needs_rehash)も提供されていることを考慮すれば、少なくともハッシュをデータベース等のストレージに格納する場合は、カラム長にゆとりを持たせておくことは大事ですね(玉虫色の回答)。

BCRYPTのハッシュコスト

PASSWORD_BCRYPT のハッシュコストのデフォルトは10となっているが、先ほど12の場合を例示した。もちろんコストが高いほど強固になるけれども、ハードウェアにそれ相応の負荷をかけることになる。php.netには、ハッシュ計算にかかる時間から逆算して適切なコストを推定するプログラムが例示されていたうのでそれを紹介する。

# password_hash() で、適切なコストを探す例
<?php
$timeTarget = 0.2; 

$cost = 9;
do {
    $cost++;
    $start = microtime(true);
    password_hash("test", PASSWORD_BCRYPT, ["cost" => $cost]);
    $end = microtime(true);
} while (($end - $start) < $timeTarget);

echo "Appropriate Cost Found: " . $cost . "\n";

1.7GHz Core i5/4GBのMBAubuntu-13.04 を仮想環境で動かした場合は、ハッシュ計算を0.2秒ぐらいにすると、適切なコストは 12 ということになった。もちろんこの辺りは環境によって調整すべき。

password_get_info

先ほどの説明の通り、password_hashは、アルゴリズムやコスト、ソルトといった情報もハッシュに含めて返す。password_get_infoは、password_hashで生成した有効なハッシュを第1引数$hashで渡すと、ハッシュに関する情報の配列を返すメソッド。

array password_get_info ( string $hash )

先ほどBCRYPTのcost12で生成したハッシュ $2y$12$OciCNOfWegjXIgyNIoO/ZOPLt6wPLWcnYTYFwmAGoN6.2dG/4itZi について確認してみる。

<?php
    # password_hash で生成した有効なハッシュ(BCRYPT/cost12)
    $hash = '$2y$12$OciCNOfWegjXIgyNIoO/ZOPLt6wPLWcnYTYFwmAGoN6.2dG/4itZi';
    $info = password_get_info($hash);
    print_r($info);
Array
(
    [algo] => 1
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )
)

algo の値が、PASSWORD_BCRYPT(1) を指しており、options にコストが格納されていることが分かる。

password_verify

password_verifyは、指定したパスワードハッシュが生のパスワードとマッチするかどうか検証するメソッド。password_get_info でも説明した通り、パスワードハッシュの検証に必要なアルゴリズムやコスト、ソルトの情報もハッシュに含まれているので簡単に検証できる(他のデータソースから用意し・・・といった面倒なことがない)。

boolean password_verify ( string $password , string $hash )

第1引数 $password にユーザーから入力された生パスワードを、第2引数 $hash にストレージ等に格納しておいたパスワードハッシュを指定すると、パスワードハッシュ値アルゴリズムやコストにしたがってパスワードとそのハッシュが一致するかを検証する。

<?php
        $hash = '$2y$12$OciCNOfWegjXIgyNIoO/ZOPLt6wPLWcnYTYFwmAGoN6.2dG/4itZi';

        $correct_password = '3kanmarusan_Passw0rd'; // みかんまるさん
        var_dump(password_verify($correct_password, $hash));

        $incorrect_password = '2kanmarusan_Passw0rd'; // にかんまるさん?
        var_dump(password_verify($incorrect_password, $hash));
# 実行結果
bool(true)
bool(false)

password_hash で生成したパスワードハッシュにはアルゴリズムやコスト、ソルトがそれぞれ含まれているので、異なるハッシュアルゴリズムが混在しても問題なくパスワードの検証ができる。

password_needs_rehash

パスワードをハッシュで保存しているときに、パスワードハッシュの強度(アルゴリズムやコスト、ソルトやストレッチングの回数など)を変更したい場合がある。ハッシュで保存している=生パスワードを知り得ないので、ハッシュ強度の変更をオフラインで実行することはできない。

したがって、ユーザーの認証が成功した時に(ここで生のパスワードが分かるので)オンラインでハッシュの変更(rehash)をする必要が出てくる。これをうまく出来るようにするのが password_needs_rehash メソッド。

boolean password_needs_rehash ( string $hash , string $algo [, string $options ] )

第1引数$hashがチェック対象となるハッシュ。第2引数は$algoはハッシュアルゴリズムで、第3引数$optionsには、第2引数のハッシュアルゴリズムがサポートするオプションを指定する。第1引数の$hashアルゴリズムやオプションが第2引数以降とマッチしなかった場合は再ハッシュが必要ということで true が返却される。言葉にするとよく分からなくなるので書いてみる。同じ事を2度書くとかイケていないけれども、ここは分かりやすさ重視で書いてます。

<?php
    # BCRYPT, cost10(デフォルト)で生成したハッシュ
    $hash = '$2y$10$fAO/bg1Ti9.yBM3wC3FyJeIfrIql9dVFx/dhTDZO.FjSSjylRRCLK';

    if(password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
        echo "need rehash" . PHP_EOL;
    } else {
        echo "not need rehash" . PHP_EOL;
    }

    # BCRYPT, cost12で生成したハッシュ
    $hash = '$2y$12$OciCNOfWegjXIgyNIoO/ZOPLt6wPLWcnYTYFwmAGoN6.2dG/4itZi';

    if(password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
        echo "need rehash" . PHP_EOL;
    } else {
        echo "not need rehash" . PHP_EOL;
    }
# 実行結果
need rehash
not need rehash

上記の例の場合、はじめのハッシュはcost10のハッシュなのでrehashが必要と判定され、2番目のハッシュはcost12のハッシュなのでrehashは不要だと判定できている。

password_verify でも説明した通り、パスワードハッシュそのものにアルゴリズムやコスト、ソルトなどが含まれており、異なるハッシュアルゴリズムが混在していもパスワードの検証は出来るが、パスワードの強度は常に最新にしていく必要がある。下記の疑似コードで実現できると思う(試してない)。

<?php
    $raw_passwd = '(なにか)'; // ユーザーの入力
    $hash = '(なにか)'; // DBなどから持ってくる

    // まず認証しようず!
    if(password_verify($raw_passwd, $hash)) {
        // パスワード認証成功
        if(password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
            // rehashが必要
            $new_hash = password_hash($raw_passwd,
                                      PASSWORD_BCRYPT,
                                      ['cost' => 12]);

            // (新しいハッシュをDBに格納)
        }
    } else {
        // パスワード認証失敗
    }

まとめ

  • PHP5.5から採用されたパスワードハッシュAPIは便利
  • ソルト+ストレッチングを内蔵したパスワードハッシュが作れて再ハッシュも簡単