パスワードのハッシュ化

お客さんや管理者のログインのパスワードをDBに保存するには、一般的に入力したパスワードの文字列をそのまま保存するのではなく、その文字列をハッシュ化した値を保存します。データベースのデータが悪者の手に渡った場合にパスワードが逆算されないためです。つまりハッシュ化された値からは元のパスワードを推定することが不可能という前提です。しかし、ハッシュ化する方法によってはブルートフォースで見破られる可能性が大きい場合があります。今日はこのハッシュ化の今昔の話です。

お客さんや管理者のログインのパスワードをDBに保存するには、一般的に入力したパスワードの文字列をそのまま保存するのではなく、その文字列をハッシュ化した値を保存します。データベースのデータが悪者の手に渡った場合にパスワードが逆算されないためです。つまりハッシュ化された値からは元のパスワードを推定することが不可能という前提です。しかし、ハッシュ化する方法によってはブルートフォースで見破られる可能性が大きい場合があります。今日はこのハッシュ化の今昔の話です。

昔のハッシュ関数

PHPにおいて代表的な昔のハッシュ関数は、md5()です。

tinkerで実行すれば、

> md5('test-password');
= "dfb450efddbb5387197c84460623675b"

と32文字の16進数のハッシュの値を返します。

違うパスワードを与えると

> md5('password-test');
= "d33964b6c484d6e5ee831552440378de"

と違う値になります。

tinkerをいったんexitして、新しいセッションとしても、

> md5('test-password');
= "dfb450efddbb5387197c84460623675b"

もちろん同じ値です。

つまり、このパスワードを認証するには、

if (md5($request->password) === $member->password) {
    echo "認証成功!";
}

と入力されたログインのパスワードをハッシュ化して、DBに保存してあるパスワード(ハッシュ化されて保存されている)と比較して値が同じであれば、認証は成功です。

しかしながら、phpのマニュアルサイトでこの関数を参照すると、

https://www.php.net/manual/ja/function.md5.php

パスワードを守るためにこの関数を使うことはおすすめしません。 ハッシュアルゴリズムの高速性がその理由です。 詳細とベストプラクティスについては、パスワードハッシュ FAQを参照ください。

警告が出てきます。

FAQでは、

よく使われるハッシュ関数である md5() や sha1() は、なぜパスワードのハッシュに適していないのですか?

MD5 や SHA1 そして SHA256 といったハッシュアルゴリズムは、 高速かつ効率的なハッシュ処理のために設計されたものです。 最近のテクノロジーやハードウェア性能をもってすれば、 これらのアルゴリズムの出力を"ブルートフォース" で(力ずくで)調べて元の入力を得るのはたやすいことです。

最近のコンピュータではハッシュアルゴリズムを高速に"逆算" できるので、 セキュリティ技術者の多くはこれらの関数をパスワードのハッシュに使わないよう強く推奨しています。

とあります。高速ゆえに問題ということに注目してください。

新しいハッシュ関数

新しいと言っても10年以上も前の2015年に登場した関数password_hash()なのですが、これが古いmd5()と違うのは、

まず、作成されるハッシュの値の長さや形態が違います。

> password_hash('test-password', PASSWORD_BCRYPT);
= "\$2y\$10\$Vgqa3vlVHWmfDB4h/ZB0su18xs/sesW5kv5cU/YfbPl4gca0aMvV6"

今度は60桁の文字列(上のバックスラッシュは$文字のエスケープですのでカウントしません)で、以下のような構成となっています。

  • $2y$(3文字): アルゴリズムの種類(Bcrypt)を示すプレフィックス。
  • 10$(3文字): アルゴリズムのコストパラメータ(ワークファクター)。
  • 次の22文字: 自動生成される、一意のランダムなソルト。
  • 最後の32文字: 実際に暗号化されたパスワードのハッシュ値。

さらに、同じパスワードでハッシュを再実行したら値が変わります。

> password_hash('test-password', PASSWORD_BCRYPT);
= "\$2y\$10\$5UpkfDO2fJXRNj2Y97vSIOGFrNxLFsCTB8EL5K28JA7IlKhnq2Gxq"

そうなると、先のmd5()と違って同じ値なら認証という判断は使えず、認証のための関数password_verify()が必要となります。

> password_verify('test-password', password_hash('test-password', PASSWORD_BCRYPT))
= true

新しいハッシュの計算量

次に、password_hash()md5()と違うのは、ハッシュの計算量です。

ハッシュの計算量というのは、ハッシュの計算に時間がかかる時間です。ブルートフォースによるパスワードの逆算は、文字列を変えてハッシュを計算しその出力が保存されているハッシュの値と同じかを機械的に行うので、時間がかかるほど逆算は難しいということになります。

md5()password_hash()の実行時間を比べてみましょう。

namespace App\Console\Commands;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;

#[Signature('hash:measure {--cost=10 : コストファクター}')]
#[Description('ハッシュ関数の時間測定')]
class MeasureHashDuration extends Command
{
    public function handle(): int
    {
        $cost = (int) $this->option('cost');

        $durations = [];
        $password = 'test-password';

        for ($i = 0; $i < 10; $i++) {
            $start = hrtime(true);
            md5($password);
            // password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
            $end = hrtime(true);

            $durations[] = ($end - $start) / 1_000_000;
        }

        $average = array_sum($durations) / count($durations);

        $this->info(sprintf("%.2f ms", $average));

        return self::SUCCESS;
    }
}

上のコマンドは、10回だけハッシュ関数を実行してその平均時間を出力します。コメントアウトでmd5()実行かpassword_has()実行か切り替えます。

まずは、md5

$ php artisan hash:measure
0.00ms

速いです。つまり計算量が低くパスワードの逆算がされやすい。

今度は、password_hash

$ php artisan hash:measure
45.21ms

時間かかりますね。コストを上げてみましょう。

$ php artisan hash:measure --cost=12
183.91 ms

もっと時間がかかります。計算量がぐっと上がりました。

コストをもっと上げてみましょう。

$ php artisan hash:measure --cost=15
1457.41 ms

これは時間かかりすぎですね。DBに保存するパスワードはよりセキュアとはなりますが、パスワードを指定する新規会員の画面において1秒以上待たされるのが今度は問題です。ここは実行するシステムのパフォーマンスや求めるセキュリティにより違うので適切な値を選ぶ必要があります。

Laravelでは

Laravelの、Hash::make()あるいはbcrypt()のハッシュの関数は、それらの定義において以下のようなpassword_hash()のコールがあります。デフォルトでコスト12を使用しています。

password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

もちろん、以下のようにcostの指定も可能ですが、名前はroundsとなっていることに注意を。

> Hash::make($password, ['rounds' => 12]);
= "\$2y\$12\$UV7P8z2USIRskp6WxYYSnuBMAKRHlEUkt0xUF378rsJWgiH0IVyL6"

> bcrypt($password, ['rounds' => 12]);
= "\$2y\$12\$gDSoLFS8buve3tZnZcsz9Oxw8gNB/mCJMqaesYMf3I8JTdt6RqsBi"
Hugo で構築されています。
テーマ StackJimmy によって設計されています。