やられアプリ BadTodo - 17.2 補足 保存するパスワードのハッシュ化

前回:やられアプリ BadTodo - 17 認証(パスワードの強度・ログアウト) - demandosigno

BadTodo - 3.4 SQLインジェクション ID・パスワードの取得で確認したように、BadTodoはDB内にパスワードが平文で保存されているという問題点があります。
パスワードはハッシュ化されて保存されていることが多いです。その理由は次回に回しますが、先にハッシュ化の方法をメモしておきます。

PHPでパスワードハッシュを作る関数

password_hash, password_verify はPHP 5 >= 5.5.0で利用可能ですが、BadTodoのPHPは 5.3.3のため使えません。
password_hash("test", PASSWORD_DEFAULT)
結果 ↓
Fatal error: Call to undefined function password_hash()

password_verify($input_pass, $hashed_pass)
結果 ↓
Fatal error: Call to undefined function password_verify()

よって crypt の方を先に試し、password_hash, password_verify は後半で試します。
ただし、PHP 5.3.7 未満の crypt や crypt_blowfish には脆弱性があるためメモ程度に留めておきます。
JVNDB - PHP の crypt 関数における認証を回避される脆弱性
JVNDB - PHP で使用される crypt_blowfish におけるクリアテキストのパスワードを容易に推測される脆弱性

crypt

crypt("パスワード", "ソルト")
(PHP 4, PHP 5, PHP 7, PHP 8)
PHP: crypt - Manual

ハッシュ方式は、salt 引数によって決まります。
crypt('test');
PHP 8.0.0 より前のバージョンでは、salt パラメータは必須ではありませんでした。salt を省略した場合は標準の 2 文字 (DES) の salt を自動生成します。salt を省略すると crypt() が作るハッシュが弱いものになるため、このパラメータを省略した場合には E_NOTICE が発生します。
(Fatal error: Uncaught ArgumentCountError: crypt() expects exactly 2 arguments, 1 given)

ハッシュ形式の一例:
例1:CRYPT_STD_DES : 標準の DES ベースのハッシュで、アルファベット "./0-9A-Za-z" からなる 2 文字の salt を使用するもの。 salt として無効な文字を使うと crypt() は失敗します。
$password = 'test'
$hashed_password = crypt($password, "aa");
$hashed_password は aaqPiZY5xR5l.

例2:CRYPT_SHA512 : SHA-512 ハッシュに $6$ で始まる 16 文字の salt を組み合わせたもの。
前提:DBに保存してあるハッシュ化されたパスワードがあるとする。Linux端末上で
# openssl passwd -6 -salt=kdBX2lbhkgjOSs.t test で生成される(-6 = $6$ = SHA-512。testが生の値)
$hashed_password = '$6$kdBX2lbhkgjOSs.t$1VwBMMg5U/apz1dKb19oXHMW8wXy/kA0XwfpPWYy7xp4aB5uBGGzzYeiE0uUYv.QX9eiroROLk8RdHMTN6GTD/' (3つ目の$より後ろがパスワード。太字部分16文字がcryptに入力される新たなソルトとして使われる。ハッシュ化するためのソルトに、ハッシュ化されたパスワードを利用するということ)

<?php
// クエリパラメータから取得
$userid =  $_GET['userid'];
$input_pass = $_GET['password'];
// 表示して確認
echo "Input Userid:" . $userid . "<br>Input Password:" . $input_pass . "<br>";
// DB内に保存してある想定のハッシュ化されたパスワード。今回はハードコーディングしておく
$hashed_pass = "\$6\$kdBX2lbhkgjOSs.t\$1VwBMMg5U/apz1dKb19oXHMW8wXy/kA0XwfpPWYy7xp4aB5uBGGzzYeiE0uUYv.QX9eiroROLk8RdHMTN6GTD/";
// 表示して確認
echo "crypt(\$input_pass, \$hashed_pass):" . "crypt('" . $input_pass . "', '" . $hashed_pass . "')" . "<br>";
echo "Hashed Password :" . $hashed_pass . "<br>";
// 入力された生パスワードをハッシュ化してDB内のパスと照合
if (crypt($input_pass, $hashed_pass) == $hashed_pass) {
    echo 'Password is valid!';
} else {
    echo crypt($input_pass, $hashed_pass) . "<br>";
    echo 'Invalid password.';
}

その他、
CRYPT_MD5 - $1$ ではじまる 12 文字の salt を使用する MD5 ハッシュ。
$1$12doKmjG$5CwUdecskAuxOb7k24yzz/
$1$ は MD5 ハッシュ値を意味して $1$12doKmjG$ までがソルト

CRYPT_BLOWFISH - Blowfish ハッシュ。salt の形式は、$2y$+2桁のコストパラメータ+$"./0-9A-Za-z"からなる22文字。この範囲外の文字を salt に使うと、crypt() は長さゼロの文字列を返します。
crypt('test', '$2y$04$1234567890123456789012');
$2rcByx51ejoM

$crypt = crypt('test', '$2y$04$1234567890123456789012');
$2y$04$123456789012345678901upHHr.tvk59gEcVReva1VSf3jvDcJ8we

password_hash() は crypt() のシンプルなラッパーであり、既存のパスワードハッシュと互換性があります。 password_hash() を使うことを推奨します。

PHP 8.2 で試す

PHP: password_hash - Manual : PHPでパスワードハッシュを作る関数
password_hash("入力パスワード", "アルゴリズム", [オプション])
(PHP 5 >= 5.5.0, PHP 7, PHP 8)

ハッシュアルゴリズムは幾つか用意されていますが、PASSWORD_DEFAULT を使うと出力の一例は次のようになります。
入力
password_hash("test", PASSWORD_DEFAULT)
出力 ↓
$2y$10$2e1JFJU80lBqrHAT2N78Qes/cl31WwDF0KPg/ziOzy4uUONs4IZmi

オプションの PASSWORD_DEFAULT は定数で、PHP8現在この定数の値は PASSWORD_BCRYPT です。新しくてより強力なアルゴリズムが PHPに追加されれば、 この定数もそれにあわせて変わっていきます。
PHP: 定義済み定数 - Manual

PASSWORD_BCRYPT (=PASSWORD_DEFAULT) を使うと、CRYPT_BLOWFISH アルゴリズムで新たなパスワードハッシュを作ります。
これは標準の crypt() 互換のハッシュで、識別子 "$2y$" を使った場合の結果を作ります。 その結果は、常に 60 文字の文字列になります。結果をデータベースに格納するときにはカラム幅を 60 文字以上にできるようなカラムを使うことをお勧めします (DEFAULTの長さは将来的に変わる可能性もあるため)255 文字くらいが適切でしょう。*1
CRYPT_BLOWFISH には "$2x$"や"$2a$"もありますが、潜在的に弱いハッシュでであり、新しくハッシュを生成する場合は "$2y$" を使うべきです。*2

ですので、先の出力例は $2y$ の計60文字で出力されます。$10$の「10」はストレッチングの回数に関するパラメータです(10回という意味ではありません)。その後に続く2e1JF...はソルトとハッシュ値です。

PHP: password_verify - Manual
password_verify("入力されたパスワード", "password_hash() で作ったハッシュ値")
パスワードがハッシュにマッチするかどうかを調べる(PHP 5 >= 5.5.0, PHP 7, PHP 8)。password_verify側にはアルゴリズム指定はなく、ハッシュ値からアルゴリズムを自動的に読み取る。
PHP: password_needs_rehash - Manual
password_needs_rehash("password_hash() が作ったハッシュ値", アルゴリズム定数, オプション)
指定したハッシュがオプションにマッチするかどうかを調べる。(ハッシュアルゴリズムやオプションが変更されたかを確認します)(PHP 5 >= 5.5.0, PHP 7, PHP 8)

# php -v
PHP 8.2.10 (cli) (built: Sep 20 2023 18:21:31) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.10, Copyright (c) Zend Technologies

# php -r "echo password_hash('a', PASSWORD_DEFAULT), PHP_EOL;"
$2y$10$oVEPTIY1lAL92rHW7QXcsepvBy7ac/zO6hXuENNxnLDV9QIxTR31q
<?php
$userid =  $_GET['userid'];
$input_pass = $_GET['password'];
echo "Input Userid:" . $userid . "<br>Input Password:" . $input_pass . "<br>";

$algorithm = PASSWORD_DEFAULT;
$hashed_DB_pass = '$2y$10$tiqIBDcjkQNIoN12P4c8ZODWwWaKPOBZPU54IPGxZ.YNpje0Jpr9a';
$hashed_input_pass = password_hash($input_pass, PASSWORD_DEFAULT);
echo 'Hashed Password from Input Password:<br>' . $hashed_input_pass . '<br>';
// 格納されたハッシュを、平文のパスワードに対して検証します
if (password_verify($input_pass, $hashed_DB_pass)) {
    // ハッシュアルゴリズムやオプションが変更されたかを確認します
    var_dump(password_needs_rehash($hashed_DB_pass, $algorithm));
    echo '<br>';
    if (password_needs_rehash($hashed_DB_pass, $algorithm)) {
        // 変更された場合は新しいハッシュを計算して、古いものを置き換えます
        $newHash = password_hash($password, $algorithm, $options);

        // ユーザーのレコードを $newHash で更新します
    }
    echo 'Password is valid!';
} else {
    echo 'Invalid password.';
}

DBのpwdカラムの桁数を増やす

[前回]指摘したように、BadTodoは初期状態で6文字しかパスワードに登録できないため増やします。

MariaDB [todo]> Desc users;
+--------+--------------+------+-----+---------+-------+
| Field  | Type         | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| id     | int(11)      | NO   |     | NULL    |       |
| userid | varchar(64)  | NO   |     | NULL    |       |
| pwd    | varchar(6)   | NO   |     | NULL    |       |
| email  | varchar(64)  | NO   |     | NULL    |       |
| icon   | varchar(128) | NO   |     | NULL    |       |
| super  | int(11)      | NO   |     | 0       |       |
+--------+--------------+------+-----+---------+-------+
 
# pwdカラムを255桁に変更
> ALTER TABLE users MODIFY pwd varchar(255);
 
> desc users;
+--------+--------------+------+-----+---------+-------+
| Field  | Type         | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| id     | int(11)      | NO   |     | NULL    |       |
| userid | varchar(64)  | NO   |     | NULL    |       |
| pwd    | varchar(255) | YES  |     | NULL    |       |
| email  | varchar(64)  | NO   |     | NULL    |       |
| icon   | varchar(128) | NO   |     | NULL    |       |
| super  | int(11)      | NO   |     | 0       |       |
+--------+--------------+------+-----+---------+-------+
 
# 変更された

MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.1.9 ALTER TABLE ステートメント

次回:やられアプリ BadTodo - 17.2 パスワードハッシュ化の目的 - demandosigno

/* -----codeの行番号----- */