Edited at

2018年のパスワードハッシュ

数年前であれば仕方なかったところですが、2018年の今となっては、パスワードハッシュの手動計算はもはや"悪"です。

まずログイン認証と称してmd5とかsha1とか書いてあるソースはゴミなので投げ捨てましょう。

hashcryptは上記に比べればずっとマシですが、使い方によっては簡単に脆弱になりえます。

あと『パスワードを暗号化する』って表現してるところも見なくていいです。

PHPには、ハッシュに関わる諸々の落とし穴を一発で解消してくれるpassword_hashという超絶便利関数があるので、これを使います。

というか、これ以外を使ってはいけません。

以下はフレームワークを使わずに実装する際の例示です。

フレームワークを使っている場合は当然その流儀に従っておきましょう。


ハッシュの実装


データベース

ユーザ情報を保存するテーブルを作成します。

パスワードカラムの文字数は、システム上のパスワード文字数上限ではなく、さらに大きめに取っておく必要があります。

2018年時点では255でよいでしょう。

CREATE TABLE `users`(

`name` varchar(255) NOT NULL,
`hash` varchar(255) NOT NULL,
/* 権限やその他の情報、略 */
PRIMARY KEY (`name`)
)


パスワードの登録

ユーザ登録時・パスワード更新時には、パスワードをハッシュ化して保存します。

暗号化とハッシュの違いについて注意しましょう。

暗号化は元の文字列に戻すことが可能で、そして元の文字列に戻すことが前提の情報に使用するものです。

それに対し、ハッシュ化は一度変換したら元に戻すことのできない、一方通行の不可逆変換です。

従って、変換後のハッシュから元のパスワードに復号することは、原理的に不可能です。

とはいっても、特定の入力値からは特定の出力値が出てくるので、その対応表さえ作ってしまえば10文字程度のパスワードであれば物理的に解読ができてしまいます。

たとえば"password"のMD5ハッシュは"5f4dcc3b5aa765d61d8327deb882cf99"です。

従ってパスワード欄に"5f4dcc3b5aa765d61d8327deb882cf99"と入っていれば、そのユーザのパスワードは"password"であろうと推測できてしまいます。

そこで、そのようなクラックに対抗するためにSALTやStretchingといった手段が作り出されていて、それらを適切に実装するかぎりではパスワードはとても安全です。

しかし、hashcryptでのハッシュ化は、そのあたりの対応を手動で行わなければなりません。

つまり、実装し忘れて ( or 実装が脆弱で ) パスワードを抜かれます。


password_hashによる実装

<?php

/* 引数バリデーションとか、省略 */

// ハッシュを作る
$hash = password_hash($_REQUEST['password'], PASSWORD_BCRYPT);

// 保存する
$sql = 'INSERT INTO users (name, hash) VALUES(:name, :hash)';
$stmt = $pdo->prepare($sql);
$stmt->execute([ ':name'=>$_REQUEST['name'], ':hash'=>$hash ]);

/* 以降の処理 */

password_hash関数は、同じパスワードを渡したとしても毎回異なる結果が出てきます。

これによって『AさんとBさんが同じハッシュだからよくあるパスワードだろう』といった推測も不可能となります。

crypt等の関数では手動実装が必要だったSALTの生成と保存、ストレッチングなどの処理を全てすっ飛ばして、これだけで安全なハッシュが保存されます。

実はpassword_hashはただのcryptのラッパーで、実際には関数内部でそのあたりの処理を行っているだけです。

しかし、その部分は表に全く出てこないので見た目がすっきりとし、実装し忘れミスもなくなるため安全性も高まります。

なおPASSWORD_BCRYPTはハッシュアルゴリズムのひとつで、CRYPT_BLOWFISHというアルゴリズムでハッシュ化します。


パスワードの認証

保存された認証情報の比較には、password_verifyを使います。

同じパスワードのAさんとBさんでハッシュが異なっていても、きちんと認証してくれます。不思議ですね。

password_verifyはあくまで生パスワードとハッシュがマッチするかを確認する関数で、ハッシュ同士の比較はできません。

つまり、SELECT * FROM users WHERE name = :name AND hash = :hashなどと書くことはできません。

一度PHP側で引き取ってから比較する必要があります。


password_verifyによる実装

<?php

/* 引数バリデーションとか、省略 */

// ユーザIDでSELECTする
$sql = 'SELECT * FROM users WHERE name = :name';
$stmt = $pdo->prepare($sql);
$stmt->execute([':name'=>$_REQUEST['name']);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// ユーザがいない
if(!$user){
echo 'ユーザ名かパスワードが正しくありません。';
exit; // exitはあくまでサンプルなので、実アプリでこんな書き方はしないこと
}

// パスワードチェック
if(!password_verify($_REQUEST['password'], $user['hash'])){
echo 'ユーザ名かパスワードが正しくありません。';
exit;
}

// ログイン
session_regenerate_id(true);
$_SESSION['login'] = true;

/* 以降の処理 */

ハッシュアルゴリズムやSALTなどの比較に必要な情報を全く使っていないのに、これだけで認証が成功します。

では、そのあたりの情報は何処にあるのかというと、ハッシュ化したパスワードと一緒にDBのhash欄に入っています。

安全という理由だけではなく、利便性の高さから言ってもpassword_verifyを使用するべきでしょう。


注意点

PASSWORD_BCRYPTによるハッシュは、入力値の前方72文字しか使いません。

つまり、73文字目以降にどんな文字を入れようが、前方72文字さえ合っていれば認証に成功してしまいます。

もっとも73文字なんてパスワード、登録できるサイトの方が少ないと思いますが。


ハッシュアルゴリズムの変更

何らかの脆弱性が発覚したのでハッシュアルゴリズムを変更しようとなった場合、もし独自実装であれば、周到に考えて実装していないかぎりとても面倒な対応作業が必要でしょう。

ハッシュ関数を使っておけば、1行変えるだけで終わりです。

    $hash = password_hash($_REQUEST['password'], PASSWORD_ARGON2I);

password_verify側は変更不要です。

これだけで、これまでに登録していたユーザはPASSWORD_BCRYPTで認証され、対応後に登録したユーザはPASSWORD_ARGON2Iで登録・認証がなされるようになります。

PASSWORD_ARGON2IはPHP7.2で実装されたハッシュアルゴリズムで、コストを任意に設定できるというすごいやつです。


ハッシュの再計算

ハッシュアルゴリズムの変更では、古いユーザは古いハッシュアルゴリズムのままでした。

これを古いユーザも新しいハッシュアルゴリズムに変更したいという場合、password_needs_rehashで比較的簡単に対応可能です。

サーバ側には元のパスワードが全く存在しないので、ユーザのログイン時を狙います。

このときにはユーザから生パスワードが送られてくるので、ログインのついでにハッシュを更新します。


password_needs_rehashによる実装

<?php

/* 引数バリデーションとか、省略 */

// ユーザIDでSELECTする
$sql = 'SELECT * FROM users WHERE name = :name';
$stmt = $pdo->prepare($sql);
$stmt->execute([ ':name'=>$_REQUEST['name']);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// ユーザがいない
if(!$user){
echo 'ユーザ名かパスワードが正しくありません。';
exit;
}

// パスワードチェック
if(!password_verify($_REQUEST['password'], $user['hash'])){
echo 'ユーザ名かパスワードが正しくありません。';
exit;
}

// 更新の必要があるかチェック
if(password_needs_rehash($user['hash'], PASSWORD_ARGON2I)){
// 更新する必要があるなら更新する
$sql = 'UPDATE users SET hash = :hash WHERE name = :name';
$stmt = $pdo->prepare($sql);
$stmt->execute([ ':name'=>$_REQUEST['name'], ':hash'=>password_hash($_REQUEST['password'], PASSWORD_ARGON2I) ] );
}

// ログイン
session_regenerate_id(true);
$_SESSION['login'] = true;

/* 以降の処理 */

password_needs_rehash関数で、ハッシュアルゴリズムを更新する必要があるかどうかを確認できます。

といっても実は、第一引数のハッシュのハッシュアルゴリズムが、第二引数(+第三引数)のハッシュアルゴリズムと等しいかどうかを見てるだけなので、強度が弱くなるような変更でもtrueになったりします。


その他


ハッシュアルゴリズムのデフォルト

ハッシュアルゴリズムはあえて特定の値を指定する必要はなく、基本的にはデフォルト値PASSWORD_DEFAULTを指定しておけば問題ありません。

PHPのバージョンアップによってデフォルト値が変更される可能性はありますが、正しい実装を行ってさえいれば、その後も何事もなく認証は正常に行われます。


バックポート

password_hashが実装されたのはPHP5.5です。

PHP5.3.7以上、5.5未満のバージョンにはバックポートが存在するので、こちらが利用できます。

とはいえ、PHP5.5未満のバージョンはそもそも存在自体が危険なので、ここだけ対応したくらいでは意味がないですが。


タイミング攻撃

password_verifyのマニュアルには『この関数は、タイミング攻撃に対して安全です。』と書かれているのですが、どうも22文字以上か未満かは区別できてしまうようです。

ちなみに"password_verifyによる実装"例には、これ以外にもタイミング攻撃に対する脆弱性が存在します。

さて何処でしょうか。


ソーシャルログインでよくない?

そもそもそれ以前の問題として、星の数ほどあるサイトにわざわざユーザ登録までしてログインするのは敷居が高いです。

唯一無二な価値のあるようなところならともかく、新聞社とかマイナーな企業・個人サイトとか、いちいち個人情報を入れてまで見たくはないですよね。

個人情報を取り扱う、郵送するなどのサービスを提供しているサイトでは使えない方法ですが、多くの場合は単にユーザを識別したいだけでしょう。

その程度であればソーシャルログインでも十分です。

ソーシャルログインであればハッシュがどうとか気にする必要は全くなくて、先方から発行されるトークンをそのまま保存しておけばいいだけなのでとっても楽ちんです。

ユーザにとってもTwitterの連携ボタンを押すだけですから気楽です。

実際、たとえばこのQiitaもGitHub、Twitter、Googleのソーシャルログインに対応しています。

そして私もQiitaのアカウントは作らず、Twitterログインだけで利用しています。

このくらいのサービスであればソーシャルログインでも十分だということです。


結論

ソーシャルログインにしとけ。

どうしても必要ならフレームワーク使え。