🔑

PHPのbcryptハッシュ値が他の言語で検証できない

2024/03/12に公開

概要

PHP側で作成したパスワードのハッシュ値が、Node.js側で検証できないという問題が起こりました。
そのため、まず使用したbcryptの仕様をおさらいし、次に問題の原因を探りました。

bcryptとは

そもそもbcryptとは何でしょうか。

bcryptは文字列からハッシュ値を求めるアルゴリズムの一つです。
特にパスワードのハッシュ化に特化しているため、パスワードハッシュ関数とも呼ばれます。

通常、パスワードをデータベースに保存する際は、平文ではなくbcryptなどで生成したハッシュ値を保存します。万が一データベースの内容が漏洩しても、元のパスワードを容易に復元することはできません。

ハッシュ値の構造

bcryptで生成されるハッシュ値は、Modular Crypt Formatに従っており、次のような構造になっています。

$[algorithm]$[cost]$[salt][hash]

例えば、passwordという文字列を入力すると$2b$08$0olY8.pa56LJzxuwPJ1HWuXyb4Zy0rTi/TV2e367LjlTb0iZYR3Aeという結果が得られます。

$2b$08$0olY8.pa56LJzxuwPJ1HWuXyb4Zy0rTi/TV2e367LjlTb0iZYR3Ae
 |  |  |                     |
 |  |  |                     hash-value = Xyb4Zy0rTi/TV2e367LjlTb0iZYR3Ae
 |  |  |
 |  |  salt = 0olY8.pa56LJzxuwPJ1HWu
 |  |
 |  cost-factor => 8 = 2^8 rounds
 |
 hash-algorithm identifier => 2b = BCrypt

詳しく見ていきましょう。

ハッシュアルゴリズム(バージョン)

先頭の$2b$の部分はハッシュアルゴリズムを表しています。2がついているのでbcryptです。
他にも$2a$や$2y$など、同じアルゴリズムでも異なるバージョンが存在します。これは後述の本題の部分で詳しく取り上げます。

ストレッチング

$08$の8はストレッチングと呼ばれるハッシュ化の反復処理回数を表しています。この場合は2^8回を表しています。これによって、ブルートフォース攻撃に対する耐性を持ちます。
ストレッチング回数は可変であり、開発者側で計算負荷を調整することができます。

ソルト

ソルトとは、パスワードのハッシュ化に使用されるランダムな文字列のことです。cost-factorに続く22文字がソルトになっています。
bcryptアルゴリズムでは、このソルトを自動的に生成し、パスワードに付加してハッシュ化を行います。
これにより、同じパスワードでも毎回異なるハッシュ値が生成されるため、レインボーテーブル攻撃を防ぐことができます。

ハッシュ値

残りの部分が実際のハッシュ化された値になります。

パスワードの検証の流れ

入力値が正しいパスワードであるかは、データベースに保存されているデータから上記のアルゴリズム、ストレッチング回数、ソルトを取得し、入力値とそれらを用いてハッシュ化を行って、ハッシュ値が一致するかどうかで確認します。

JavaScriptにおけるbcryptパッケージ

JavaScriptでbcryptを利用する場合、bcryptまたはbcryptjsパッケージの2種が選択されているようです。
両者の違いは下記のとおりです。

bcryptパッケージ

C++で実装されているネイティブパッケージです。高速な計算処理を行うことができます。

bcryptjsパッケージ

純粋なJavaScriptで実装されており、上記bcryptパッケージに比べてパフォーマンスは劣りますが、依存関係が無いため導入が容易で、フロントエンドとバックエンドの両方で利用することができます。

bcryptとbcryptjsは相互検証可能である

どちらのパッケージもbcryptアルゴリズムを実装しているので、片方で求めたハッシュ値は、当然もう片方の検証用関数で検証できます。

import bcrypt from "bcrypt";
import bcryptjs from "bcryptjs";

const password = "password";

const hashedPassword1 = await bcrypt.hash(password, 8);
const hashedPassword2 = await bcryptjs.hash(password, 8);

// bcryptでハッシュ化したパスワードをbcryptjsで検証
const isMatch1 = await bcryptjs.compare(password, hashedPassword1);
console.log("isMatch1:", isMatch1); // true

// bcryptjsでハッシュ化したパスワードをbcryptで検証
const isMatch2 = await bcrypt.compare(password, hashedPassword2);
console.log("isMatch2:", isMatch2); // true

本題 ハッシュ値を相互に検証できないパターンがある

どの言語・パッケージでもbcryptのハッシュ値を相互に検証できそうですが、失敗するパターンに遭遇しました。
今回私が遭遇したのは、PHP -> Node.jsの問題です。

PHPにはpassword_hashという組み込み関数があり、bcryptを用いてハッシュ値を生成できます。

echo password_hash("password", PASSWORD_DEFAULT);
// $2y$10$uCfZzmsfkzLDEXvNjMk44edggmTDmNtHFmynu2y/.UXVO4qJ8Uf2i

このPHPで求めたハッシュ値ですが、JavaScriptのbcryptパッケージでは検証できません。一方、bcryptjsパッケージでは検証できます

import bcrypt from "bcrypt";
import bcryptjs from "bcryptjs";

const password = "password";

// PHPのpassword_hashで作成したハッシュ化パスワード
const hashedPasswordByPHP =
  "$2y$10$uCfZzmsfkzLDEXvNjMk44edggmTDmNtHFmynu2y/.UXVO4qJ8Uf2i";

// PHPのpassword_hashで作成したハッシュ化パスワードをbcryptで検証(失敗)
const isMatch3 = await bcrypt.compare(password, hashedPasswordByPHP);
console.log("isMatch3:", isMatch3); // false

// PHPのpassword_hashで作成したハッシュ化パスワードをbcryptjsで検証(成功)
const isMatch4 = await bcryptjs.compare(password, hashedPasswordByPHP);
console.log("isMatch4:", isMatch4); // true

逆に、JavaScriptのbcryptパッケージで生成したハッシュ値は、PHPのphp_verify(検証用関数)で検証できます

$password = "password";
$hashed_password_by_js = '$2b$08$coJ5SchcRpgz1Ntyei5YM.mPZam/WHqdp.djd1hWcGy0YmEW/JQoK';

echo password_verify($password, $hashed_password_by_js); // True

この違いはどうして起こるのでしょうか?

結論から述べると、対応しているbcryptのバージョンの問題です。

bcryptのバージョン

まず、bcrypt.hash()、bcryptjs.hash()、password_hash()で生成したハッシュ値を並べて見てみましょう。

// JS bcrypt.hash()
$2b$08$0olY8.pa56LJzxuwPJ1HWuXyb4Zy0rTi/TV2e367LjlTb0iZYR3Ae

// JS bcryptjs.hash()
$2a$08$KLPwAM0MIhbosiG9gvABq.z7wA9ZG1nZ4xHRUmeR4Iw19mSQ0WXeW

// PHP password_hash()
$2y$10$uCfZzmsfkzLDEXvNjMk44edggmTDmNtHFmynu2y/.UXVO4qJ8Uf2i

ハッシュ値の先頭(バージョン)がそれぞれ2b、2a、2yと異なっていることがわかります。

もともとBlowfish暗号で作成されたハッシュの接頭辞は$2$と定義されており、そこからアルゴリズムに修正が加わって各バージョンが生まれました[1]。

バージョン 説明
2 オリジナルの仕様。
2a オリジナルの仕様にASCII以外の文字やnull終端子の扱いを定義したもの。
2b OpenBSDの実装でパスワード長の扱いに不備があったため、2aのバージョンを修正したもの。
2x PHPのハッシュアルゴリズム(crypt_hash)の実装にバグがあったため生まれたのが2x。
間違ったハッシュアルゴリズムを使っていることを示すために使われる。[2]
2y 2xと対比して、修正されたアルゴリズム(crypt_blowfish)で生成されたハッシュ値であることを示すために使われている。[2]

[1]:Wikipedia bcrypt
[2]:Openwall

つまり、PHPのbcryptの実装のバグのせいで生まれたのが2yで、イレギュラーなバージョンであることが伺えます。2yは実質2aです
ほとんどの言語でハッシュ値を生成する場合は2aまたは2bになります。

Node.js bcryptパッケージは$2y$に対応していない

次に、bcryptパッケージのソースコードを読んでハッシュ化の実装を確認しましょう。C++で実装されています。

注目するのはbcrypt.ccに含まれるbcrypt関数です。
パスワード(key)とソルト(salt)を使って、bcryptによるハッシュ化を行っています。

bcrypt関数の全体
void
bcrypt(const char *key, size_t key_len, const char *salt, char *encrypted)
{
	blf_ctx state;
	u_int32_t rounds, i, k;
	u_int16_t j;
	u_int8_t salt_len, logr, minor;
	u_int8_t ciphertext[4 * BCRYPT_BLOCKS+1] = "OrpheanBeholderScryDoubt";
	u_int8_t csalt[BCRYPT_MAXSALT];
	u_int32_t cdata[BCRYPT_BLOCKS];
	int n;

	/* Discard "$" identifier */
	salt++;

	if (*salt > BCRYPT_VERSION) {
		/* How do I handle errors ? Return ':' */
		strcpy(encrypted, error);
		return;
	}

	/* Check for minor versions */
	if (salt[1] != '$') {
		 switch (salt[1]) {
		 case 'a': /* 'ab' should not yield the same as 'abab' */
		 case 'b': /* cap input length at 72 bytes */
			 minor = salt[1];
			 salt++;
			 break;
		 default:
			 strcpy(encrypted, error);
			 return;
		 }
	} else
		 minor = 0;

	/* Discard version + "$" identifier */
	salt += 2;

	if (salt[2] != '$') {
		/* Out of sync with passwd entry */
		strcpy(encrypted, error);
		return;
	}

	/* Computer power doesn't increase linear, 2^x should be fine */
	n = atoi(salt);
	if (n > 31 || n < 0) {
		strcpy(encrypted, error);
		return;
	}
	logr = (u_int8_t)n;
	if ((rounds = (u_int32_t) 1 << logr) < BCRYPT_MINROUNDS) {
		strcpy(encrypted, error);
		return;
	}

	/* Discard num rounds + "$" identifier */
	salt += 3;

	if (strlen(salt) * 3 / 4 < BCRYPT_MAXSALT) {
		strcpy(encrypted, error);
		return;
	}

	/* We dont want the base64 salt but the raw data */
	decode_base64(csalt, BCRYPT_MAXSALT, (u_int8_t *) salt);
	salt_len = BCRYPT_MAXSALT;
	if (minor <= 'a')
		key_len = (u_int8_t)(key_len + (minor >= 'a' ? 1 : 0));
	else
	{
		/* cap key_len at the actual maximum supported
		* length here to avoid integer wraparound */
		if (key_len > 72)
			key_len = 72;
		key_len++; /* include the NUL */
	}


	/* Setting up S-Boxes and Subkeys */
	Blowfish_initstate(&state);
	Blowfish_expandstate(&state, csalt, salt_len,
		(u_int8_t *) key, key_len);
	for (k = 0; k < rounds; k++) {
		Blowfish_expand0state(&state, (u_int8_t *) key, key_len);
		Blowfish_expand0state(&state, csalt, salt_len);
	}

 	/* This can be precomputed later */
	j = 0;
	for (i = 0; i < BCRYPT_BLOCKS; i++)
		cdata[i] = Blowfish_stream2word(ciphertext, 4 * BCRYPT_BLOCKS, &j);

	/* Now do the encryption */
	for (k = 0; k < 64; k++)
		blf_enc(&state, cdata, BCRYPT_BLOCKS / 2);

	for (i = 0; i < BCRYPT_BLOCKS; i++) {
		ciphertext[4 * i + 3] = cdata[i] & 0xff;
		cdata[i] = cdata[i] >> 8;
		ciphertext[4 * i + 2] = cdata[i] & 0xff;
		cdata[i] = cdata[i] >> 8;
		ciphertext[4 * i + 1] = cdata[i] & 0xff;
		cdata[i] = cdata[i] >> 8;
		ciphertext[4 * i + 0] = cdata[i] & 0xff;
	}

	i = 0;
	encrypted[i++] = '$';
	encrypted[i++] = BCRYPT_VERSION;
	if (minor)
		encrypted[i++] = minor;
	encrypted[i++] = '$';

	snprintf(encrypted + i, 4, "%2.2u$", logr & 0x001F);

	encode_base64((u_int8_t *) encrypted + i + 3, csalt, BCRYPT_MAXSALT);
	encode_base64((u_int8_t *) encrypted + strlen(encrypted), ciphertext,
		4 * BCRYPT_BLOCKS - 1);
	memset(&state, 0, sizeof(state));
	memset(ciphertext, 0, sizeof(ciphertext));
	memset(csalt, 0, sizeof(csalt));
	memset(cdata, 0, sizeof(cdata));
}

その中のバージョンによる分岐処理が今回の問題点です。
接頭辞が2aまたは2b以外の場合、エラーとみなされ、処理が中断します。

/* Check for minor versions */
	if (salt[1] != '$') {
		 switch (salt[1]) {
		 case 'a': /* 'ab' should not yield the same as 'abab' */
		 case 'b': /* cap input length at 72 bytes */
			 minor = salt[1];
			 salt++;
			 break;
		 default:
			 strcpy(encrypted, error);
			 return;
		 }
	} else
		 minor = 0;

(これは推察ですが、問題のあるバージョン2xを弾くためだと思われます)

このため、PHPで生成したハッシュ値($2y$...)をNode.js側のbcryptパッケージでは検証できないというわけでした。

調査していて見つけたのですが、同様の問題がPHP -> Rubyでも起きるようです。

https://stackoverflow.com/questions/20980859/using-bcrypt-ruby-to-validate-hashed-passwords-using-version-2y

解決方法

Githubのissuesで提案されていた裏技的な解決方法です。
https://github.com/kelektiv/node.bcrypt.js/issues/849

PHP側で生成したハッシュ値の"$2y$"を"$2a$"に置換すると、Node.jsのbcryptパッケージで検証することができます

import bcrypt from "bcrypt";

const password = "password";

// $2y$を$2a$に変更
let hashedPasswordByPHP = "$2y$10$mYryPQdlogRTzqsSKLGmEuJc.LlsPjw.GFlbREOmw65b4EyY65Hpa";
hashedPasswordByPHP = hashedPasswordByPHP.replace(/^\$2y\$/, '$2a$');

// PHPのpassword_hashで作成したハッシュ化パスワードをbcryptで検証(成功)
const isMatch = await bcrypt.compare(password, hashedPasswordByPHP);
console.log("isMatch:", isMatch); // true

ただし、置換が正しく行われているかの担保が必要になるので、もしPHPで管理しているユーザー情報を用いてNode.jsで認証したいなんて場合は、bcryptjsパッケージを使ったほうが楽でしょう。

Discussion