解明!password_hash関数で生成される文字列の正体

こんにちは、CTOの山岡(@hiro_y)です。

(この記事は、来る2022年4月9日から開催されるPHPerKaigi 2022登壇応募したものの選出されなかった題材をブログに書くことで供養しようという試みです。)

Webシステムでパスワードを保存するとき、そのままの値(平文)で保存してはいけません。データベースの中身が何かしらのインシデントで漏洩してしまった場合、パスワードの内容が明らかになってしまうからです(漏洩の時点でもっと酷い事態になっていそうではありますが…)。では、どのような対策を行えばよいのでしょうか。

パスワードはハッシュ化しよう

一つ目の対策は、パスワードを秘密鍵を使って暗号化、保存する方法です。

その場合、それぞれの暗号化に共通の秘密鍵を使わないことが大切です。また、初期化ベクトル(IV)としてそれぞれ異なるものを用意、ブロック暗号化モードを利用して暗号化した結果が同じパスワードでも異なるように工夫する必要があります。パスワードの検証をするときは、暗号化されたパスワードを復号して比較することになります。

…こう書いているだけでちょっと面倒ですね。実際、正しい手順で暗号化されていないばかりに不正アクセスからの漏洩事故が過去に何度か発生しています。あとは単純に、昨今簡単に手に入るようになったマシンパワーで解読してしまえるという話もあります。

そこで、現在は二つ目の方法、ソルトと呼ばれる文字列を付けてハッシュ化を行い、ストレッチングを行うことでより強い仕組みを作る手法が主流になってきています。パスワードに適当な文字列(ソルトと呼ばれる)を付けてハッシュ化した上で、できあがった文字列をさらに何度も繰り返しハッシュ化する(ストレッチングと呼ばれる)という仕組みです。検証の際には、同じ方式でハッシュ化を行い、保存しておいたハッシュ値と同値になることを確かめます。

それも面倒なのでは?と思われるかもしれません。しかしPHPの場合、実は1つの関数で済ませられるようになっています。password_hash関数です(パスワードの検証にはpassword_verify関数を用います)。password_hash関数を初めて聞く方もいらっしゃるかもしれません。しかし例えば、人気のあるフレームワークのLaravelでも内部的に使われています(\Illuminate\Hashing名前空間の各実装を参照のこと)。知らないうちに使っているケースも多いかもしれません。

password_hash関数で作られる文字列

password_hash関数は次のように使います。

<?
$hash = password_hash($password, \PASSWORD_BCRYPT);

2つ目の引数でハッシュ化に用いるアルゴリズムを指定できます(オプション)。ちなみに \PASSWORD_DEFAULT という値も指定できるので、細かな指定をせずにそちらを使うのもアリです(将来的にデフォルトの値がより強いアルゴリズムに変わったとき、自然に移行できます。ただアルゴリズムが混在してしまう可能性もあるので、お好みでどうぞ)。今回は広く使われているbcryptを指定しています。他にArgon2i、Argon2idを指定することができるのですが、使うにはArgon2のサポートを有効にしてPHPがビルドされている必要があります。

password_hash関数を用いるとソルトのランダムな生成とストレッチングを一気にやってくれます。では、生成された文字列はどのようになっているでしょうか。試しに password という文字列をハッシュ化すると、次のような値が返ってきました。

<?
$hash = password_hash('password', \PASSWORD_BCRYPT);
// => $2y$10$5DEwQg2UN.gD1tUoSjJq9.2zFIoVGSNJuDH.SBkhIkRNiLtbgSnfi

この文字列には、bcryptのバージョンの情報、ストレッチングのコスト、ソルト、そしてハッシュ化された結果がそれぞれ含まれていて、 $ がセパレータ文字です。

2y はbcryptのバージョンを示します。bcryptには他に 2a 2b 2x 等のバージョンがあります。bcryptのバージョンについては後で解説します。

次の 10 がストレッチングのコストです。10回ではなく、2の10乗、つまり1024回を示します。この回数は、password_hash関数の3つ目の引数(オプション)で変更できます。増やせばその分強いハッシュになるのですが、計算コストが増えてしまうので適切な値を設定する必要があります。

その次の22文字、 5DEwQg2UN.gD1tUoSjJq9. はソルトです。ソルトはModular Crypt Formatの仕様で、 [a-zA-Z0-9./] の文字で構成されている文字列である必要があります。同じパスワードを使って何度かpassword_hash関数を実行すると、異なるソルトが使われているのがわかると思います。Modular Crypt Formatについても後で解説します。

そして最後、残りの31文字の 2zFIoVGSNJuDH.SBkhIkRNiLtbgSnfi がハッシュ化された文字列となります。

パスワードの検証は非常に簡単です。

<?
if (password_verify($password, $savedHash)) {
    // パスワード検証に成功

password_verify関数の2つ目の引数として、password_hash関数で作って保存(データベース等に)しておいた文字列を渡してあげればOKです。先ほど書いたように文字列にハッシュ化に必要な情報が含まれているので、検証の際にアルゴリズムを指定する必要はありません。

bcryptのバージョンについて

さて、bcryptとは何でしょうか。Wikipediaの解説から引用すると「Blowfish暗号を基盤としたパスワードハッシュ化関数」です。開発されてから何度か改良が加えられ、複数のバージョンが存在します。

バージョン 説明
2 オリジナル。Modular Crypt Format(後述)で 2 がbcryptを示すものとされた
2a ASCII文字以外の文字列対応(UTF-8でエンコード)、NULL終端を含むように
2x、2y PHPのbcrypt実装に誤りが見つかり、修正された。修正前のものを 2x 、修正後のものを 2y と呼んで区別できるようにした
2b OpenBSDの実装でパスワード長の扱いに不備があり、修正された

バージョンの話の中にPHPが出てきますね。先ほどのpassword_hash関数で生成された文字列で表現されていたbcryptのバージョンは 2y です。

ここで試しにRubyで一般的に使われ、Ruby on Railsでも使われているbcrypt-rubyで同じように password という文字列をハッシュ化してみると、次のようになります。

require 'bcrypt'
hash = BCrypt::Password.create('password')
# => "$2a$12$aOJOHaGigNu5gue.PYVhNOfwE/lwhJ0xBAOMdAQHlgY9sezVwgoFe"

バージョンが 2a となっていて、PHPのpassword_hash関数で作られた結果と違うのがわかります。あとはストレッチングのコストが 12 で、PHPのデフォルトの 10 と違っていますね。

実質bcryptの 2a2y は同じもの( 2y はPHPの実装上の不具合が修正されたもの)なので、この文字列はPHPのアプリケーションでも検証できます。

<?
$result = password_verify('password', '$2a$12$aOJOHaGigNu5gue.PYVhNOfwE/lwhJ0xBAOMdAQHlgY9sezVwgoFe');
// => true

つい最近Ruby on Railsで作られたシステムをPHPで作り直すということをしたのですが、この互換性のおかげでデータベースに保存されていたパスワードのハッシュ値をそのまま移行して使うことができました。

Modular Crypt Formatとは

ここまで何度か「Modular Crypt Format」というフォーマットが登場しました。Modular Crypt Formatは元々、OpenBSDのpasswordファイルにパスワードを格納するために定められたフォーマットです。

様々なアルゴリズムで生成されたパスワードのハッシュをどの方式でハッシュ化されたのか見分けが付くようになっています。詳しくはPasslibのドキュメントに記載されているので、興味を持った方はご覧ください。

Modular Crypt Formatに従った文字列は以下の情報を含みます。

要素 説明
identifier ハッシュ化の方式を表す文字列( 2y など)
param / value ストレッチングのコストなどハッシュ化に必要なパラメータ
salt ソルトとなる文字列
digest 実際のハッシュ値

identifierには他に 1 (MD5)、 5 (SHA128)、 6 (SHA256)などがあります。

PHPのpassword_hash関数やRubyのbcryptで生成された文字列がその方式に従っているのがわかります。ちなみにハッシュ値は一般的にバイナリとなりますが、そのままでは扱いづらいので、Base64によく似たテーブルを用いてエンコードした結果が文字列として使われます。

まとめ

さて、まとめです。

  • パスワードはハッシュ化して保存しよう
  • PHPだとpassword_hash関数、password_verify関数があって便利
  • 生成されるハッシュはModular Crypt Formatに従っている

Modular Cyrpt Formatのような方式が決まっていて、それに従った結果をライブラリが返してくれるから安心してPHPとRubyで互換性のあるハッシュ化が行えるというわけです。

以前はフレームワーク独自の暗号化やハッシュ化を行われているケースもありましたが、最近のPHPのフレームワークだとpassword_hash関数が使われていることが多く、他のフレームワークや言語を使うことになっても互換性が保たれる仕組みが整っていると言えます。

フレームワークを使っていると、実際にどのような値がパスワードのハッシュとしてデータベースに保存されているか意識することがないかもしれません。でも時折、このデータベースに保存されているのは何の文字列だろう…という感じで疑問を持って、調べてみるのも面白いですよ。