From ea07bca84b55986e3d7b26cbe0fa476d7d31f614 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 2 Oct 2026 11:17:59 +0000 Subject: [PATCH 1/9] bcrypt_sha256 --- ext/standard/config.m4 | 2 + ext/standard/password.c | 277 ++++++++++++++++++ ext/standard/password.stub.php | 6 + ext/standard/password_arginfo.h | 4 +- ext/standard/php_password.h | 2 + .../password_bcrypt_sha256_errors.phpt | 27 ++ .../password_bcrypt_sha256_get_info.phpt | 50 ++++ .../password/password_bcrypt_sha256_hash.phpt | 37 +++ .../password_bcrypt_sha256_needs_rehash.phpt | 25 ++ .../password_bcrypt_sha256_verify.phpt | 40 +++ 10 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_errors.phpt create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_hash.phpt create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_verify.phpt diff --git a/ext/standard/config.m4 b/ext/standard/config.m4 index 7edfcf1da1f5..6a534ea6317f 100644 --- a/ext/standard/config.m4 +++ b/ext/standard/config.m4 @@ -457,5 +457,7 @@ PHP_NEW_EXTENSION([standard], m4_normalize([ PHP_ADD_BUILD_DIR([$ext_builddir/libavifinfo]) +PHP_ADD_EXTENSION_DEP([standard], [hash]) + PHP_ADD_MAKEFILE_FRAGMENT PHP_INSTALL_HEADERS([ext/standard/]) diff --git a/ext/standard/password.c b/ext/standard/password.c index a28ceb7e0ced..38388b8e5585 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -24,6 +24,8 @@ #include "zend_interfaces.h" #include "info.h" #include "ext/random/php_random_csprng.h" +#include "ext/hash/php_hash.h" /* Needed for PHP_HASH_API in ext/hash/php_hash_sha.h */ +#include "ext/hash/php_hash_sha.h" #include "password_arginfo.h" #ifdef HAVE_ARGON2LIB #include "argon2.h" @@ -228,6 +230,277 @@ const php_password_algo php_password_algo_bcrypt = { php_password_bcrypt_valid, }; +/* bcrypt-sha256 implementation. + * + * Plain bcrypt truncates passwords at 72 bytes and, on some implementations, + * at the first NUL byte. To avoid both quirks, the password is first run + * through HMAC-SHA256 keyed with the salt; the 32-byte digest is base64 + * encoded (44 ASCII bytes, no NUL, well under 72) and *that* is what gets + * bcrypt hashed. This mirrors passlib's bcrypt_sha256 (format version 2). + * SHA256 is taken from the hash extension. */ + +#define PHP_PASSWORD_BCRYPT_SHA256_PREFIX "$bcrypt-sha256$v=2,t=2b,r=" +#define PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN (sizeof(PHP_PASSWORD_BCRYPT_SHA256_PREFIX) - 1) + +static bool php_password_b64char(unsigned char c) +{ + return c == '.' || c == '/' || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); +} + +static void php_password_hmac_sha256(const unsigned char *key, size_t key_len, + const unsigned char *msg, size_t msg_len, unsigned char digest[32]) +{ + PHP_SHA256_CTX ctx; + unsigned char k_ipad[64], k_opad[64], key_hash[32]; + size_t i; + + /* If the key is longer than the block size (64), hash it first. Our key is + * always the 22-byte salt, so this branch is never taken in practice. */ + if (key_len > 64) { + PHP_SHA256Init(&ctx); + PHP_SHA256Update(&ctx, key, key_len); + PHP_SHA256Final(key_hash, &ctx); + key = key_hash; + key_len = 32; + } + + for (i = 0; i < 64; i++) { + unsigned char k = (i < key_len) ? key[i] : 0; + k_ipad[i] = k ^ 0x36; + k_opad[i] = k ^ 0x5c; + } + + PHP_SHA256Init(&ctx); + PHP_SHA256Update(&ctx, k_ipad, 64); + PHP_SHA256Update(&ctx, msg, msg_len); + PHP_SHA256Final(key_hash, &ctx); + + PHP_SHA256Init(&ctx); + PHP_SHA256Update(&ctx, k_opad, 64); + PHP_SHA256Update(&ctx, key_hash, 32); + PHP_SHA256Final(digest, &ctx); +} + +/* Validate a bcrypt-sha256 hash and, on success, extract its cost, salt and + * digest. Layout: + * $bcrypt-sha256$v=2,t=2b,r=$$ (82 or 83 bytes) + */ +static bool php_password_bcrypt_sha256_parse(const zend_string *hash, + zend_long *cost, const char **salt, const char **digest) +{ + const char *h = ZSTR_VAL(hash); + const char *p; + size_t len = ZSTR_LEN(hash); + size_t i; + int c; + + if (len < 82 || len > 83) { + return false; + } + if (memcmp(h, PHP_PASSWORD_BCRYPT_SHA256_PREFIX, PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN) != 0) { + return false; + } + p = h + PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN; + + if (*p < '0' || *p > '9') { + return false; + } + c = *p - '0'; + p++; + if (*p >= '0' && *p <= '9') { + c = c * 10 + (*p - '0'); + p++; + } + if (c < 4 || c > 31 || *p != '$') { + return false; + } + p++; + + if (len - (size_t)(p - h) != 54) { + return false; + } + for (i = 0; i < 22; i++) { + if (!php_password_b64char((unsigned char) p[i])) { + return false; + } + } + if (p[22] != '$') { + return false; + } + for (i = 0; i < 31; i++) { + if (!php_password_b64char((unsigned char) p[23 + i])) { + return false; + } + } + + *cost = c; + *salt = p; + *digest = p + 23; + return true; +} + +static bool php_password_bcrypt_sha256_valid(const zend_string *hash) +{ + zend_long cost; + const char *salt, *digest; + return php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest); +} + +static int php_password_bcrypt_sha256_get_info(zval *return_value, const zend_string *hash) +{ + zend_long cost; + const char *salt, *digest; + + if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { + return FAILURE; + } + add_assoc_long(return_value, "cost", cost); + return SUCCESS; +} + +static bool php_password_bcrypt_sha256_needs_rehash(const zend_string *hash, zend_array *options) +{ + zend_long cost; + const char *salt, *digest; + zval *znew_cost; + zend_long new_cost = PHP_PASSWORD_BCRYPT_SHA256_COST; + + if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { + return true; + } + if (options && (znew_cost = zend_hash_str_find(options, "cost", sizeof("cost") - 1)) != NULL) { + new_cost = zval_get_long(znew_cost); + } + return cost != new_cost; +} + +static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, zend_array *options) +{ + zval *zcost; + zend_long cost = PHP_PASSWORD_BCRYPT_SHA256_COST; + zend_string *salt, *key, *setting, *raw, *result = NULL; + unsigned char mac[32]; + char setting_prefix[16]; + size_t prefix_len; + char out[84]; + int out_len; + + if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost") - 1)) != NULL) { + cost = zval_get_long(zcost); + } + if (cost < 4 || cost > 31) { + zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); + return NULL; + } + + if (!(salt = php_password_get_salt(NULL, 22, options))) { + return NULL; + } + + /* Pre-hash with HMAC-SHA256 (key = the 22-char salt as ASCII bytes) and + * base64-encode the 32-byte digest. The resulting 44-byte string is what is + * handed to bcrypt, so any NUL bytes or length beyond 72 in the original + * password are neutralized. */ + php_password_hmac_sha256((const unsigned char *) ZSTR_VAL(salt), ZSTR_LEN(salt), + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); + + key = php_base64_encode(mac, sizeof(mac)); + ZEND_SECURE_ZERO(mac, sizeof(mac)); + + prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); + setting = zend_string_concat2(setting_prefix, prefix_len, ZSTR_VAL(salt), ZSTR_LEN(salt)); + + raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); + zend_string_release_ex(setting, 0); + zend_string_release_ex(key, 0); + + if (!raw || ZSTR_LEN(raw) < 60) { + if (raw) { + zend_string_free(raw); + } + zend_string_release_ex(salt, 0); + return NULL; + } + + /* Relabel the $2y$ result into the bcrypt-sha256 format. The digest is the + * last 31 characters of the 60-byte bcrypt output. */ + out_len = snprintf(out, sizeof(out), "$bcrypt-sha256$v=2,t=2b,r=%" ZEND_LONG_FMT_SPEC "$%s$%s", + cost, ZSTR_VAL(salt), ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31)); + zend_string_release_ex(salt, 0); + zend_string_free(raw); + + if (out_len <= 0 || (size_t) out_len >= sizeof(out)) { + return NULL; + } + result = zend_string_init(out, (size_t) out_len, 0); + return result; +} + +static bool php_password_bcrypt_sha256_verify(const zend_string *password, const zend_string *hash) +{ + zend_long cost; + const char *salt, *digest; + unsigned char mac[32]; + zend_string *key, *setting, *raw; + char setting_prefix[16]; + size_t prefix_len; + const volatile unsigned char *ra, *rb; + size_t i; + int r; + bool ret; + + if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { + return false; + } + + php_password_hmac_sha256((const unsigned char *) salt, 22, + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); + + key = php_base64_encode(mac, sizeof(mac)); + ZEND_SECURE_ZERO(mac, sizeof(mac)); + + prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); + setting = zend_string_concat2(setting_prefix, prefix_len, salt, 22); + + raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); + zend_string_release_ex(setting, 0); + zend_string_release_ex(key, 0); + + if (!raw || ZSTR_LEN(raw) < 60) { + if (raw) { + zend_string_free(raw); + } + return false; + } + + /* Constant-time comparison of the 31-byte digests. The salt portion of the + * bcrypt output may differ from the stored salt (bcrypt re-encodes the 128-bit + * salt into 22 base64 chars, and the 4 unused padding bits can change the last + * character), but both encodings decode to the same salt bytes, so the digest + * is identical. */ + ra = (const volatile unsigned char *)(ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31)); + rb = (const volatile unsigned char *)digest; + r = 0; + for (i = 0; i < 31; i++) { + r |= ra[i] ^ rb[i]; + } + ret = (r == 0); + + zend_string_free(raw); + return ret; +} + +const php_password_algo php_password_algo_bcrypt_sha256 = { + "bcrypt-sha256", + php_password_bcrypt_sha256_hash, + php_password_bcrypt_sha256_verify, + php_password_bcrypt_sha256_needs_rehash, + php_password_bcrypt_sha256_get_info, + php_password_bcrypt_sha256_valid, +}; #ifdef HAVE_ARGON2LIB /* argon2i/argon2id shared implementation */ @@ -425,6 +698,10 @@ PHP_MINIT_FUNCTION(password) /* {{{ */ return FAILURE; } + if (FAILURE == php_password_algo_register("bcrypt-sha256", &php_password_algo_bcrypt_sha256)) { + return FAILURE; + } + #ifdef HAVE_ARGON2LIB if (FAILURE == php_password_algo_register("argon2i", &php_password_algo_argon2i)) { return FAILURE; diff --git a/ext/standard/password.stub.php b/ext/standard/password.stub.php index b4758ef54b12..d9e02cc6b392 100644 --- a/ext/standard/password.stub.php +++ b/ext/standard/password.stub.php @@ -9,6 +9,12 @@ * @cvalue PHP_PASSWORD_BCRYPT_COST */ const PASSWORD_BCRYPT_DEFAULT_COST = UNKNOWN; +const PASSWORD_BCRYPT_SHA256 = "bcrypt-sha256"; +/** + * @var int + * @cvalue PHP_PASSWORD_BCRYPT_SHA256_COST + */ +const PASSWORD_BCRYPT_SHA256_DEFAULT_COST = UNKNOWN; #ifdef HAVE_ARGON2LIB const PASSWORD_ARGON2I = "argon2i"; diff --git a/ext/standard/password_arginfo.h b/ext/standard/password_arginfo.h index 2a7b19f6108a..a865953eb85d 100644 --- a/ext/standard/password_arginfo.h +++ b/ext/standard/password_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit password.stub.php instead. - * Stub hash: f61df8d477588718e0eb1b055e5a3e138e6bcad3 */ + * Stub hash: 98c86e35c451499dcfad85d839af20cfe03f7930 */ #include "zend_constants.h" @@ -8,6 +8,8 @@ static void register_password_symbols(int module_number) REGISTER_STRING_CONSTANT("PASSWORD_DEFAULT", "2y", CONST_PERSISTENT); REGISTER_STRING_CONSTANT("PASSWORD_BCRYPT", "2y", CONST_PERSISTENT); REGISTER_LONG_CONSTANT("PASSWORD_BCRYPT_DEFAULT_COST", PHP_PASSWORD_BCRYPT_COST, CONST_PERSISTENT); + REGISTER_STRING_CONSTANT("PASSWORD_BCRYPT_SHA256", "bcrypt-sha256", CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("PASSWORD_BCRYPT_SHA256_DEFAULT_COST", PHP_PASSWORD_BCRYPT_SHA256_COST, CONST_PERSISTENT); #if defined(HAVE_ARGON2LIB) REGISTER_STRING_CONSTANT("PASSWORD_ARGON2I", "argon2i", CONST_PERSISTENT); REGISTER_STRING_CONSTANT("PASSWORD_ARGON2ID", "argon2id", CONST_PERSISTENT); diff --git a/ext/standard/php_password.h b/ext/standard/php_password.h index 6442b782c256..adac8ef2e30d 100644 --- a/ext/standard/php_password.h +++ b/ext/standard/php_password.h @@ -21,6 +21,7 @@ PHP_MSHUTDOWN_FUNCTION(password); #define PHP_PASSWORD_DEFAULT PHP_PASSWORD_BCRYPT #define PHP_PASSWORD_BCRYPT_COST 12 +#define PHP_PASSWORD_BCRYPT_SHA256_COST 12 #ifdef HAVE_ARGON2LIB /** @@ -44,6 +45,7 @@ typedef struct _php_password_algo { } php_password_algo; extern const php_password_algo php_password_algo_bcrypt; +extern const php_password_algo php_password_algo_bcrypt_sha256; #ifdef HAVE_ARGON2LIB extern const php_password_algo php_password_algo_argon2i; extern const php_password_algo php_password_algo_argon2id; diff --git a/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt b/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt new file mode 100644 index 000000000000..3fe80bb2c95b --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt @@ -0,0 +1,27 @@ +--TEST-- +Test error operation of password_hash() with bcrypt-sha256 +--FILE-- + 3]); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +try { + password_hash("foo", PASSWORD_BCRYPT_SHA256, ["cost" => 32]); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +// Unlike bcrypt, a NUL byte is allowed: the HMAC pre-hash removes the quirk. +var_dump(password_verify("foo\x00bar", password_hash("foo\x00bar", PASSWORD_BCRYPT_SHA256))); + +echo "OK!"; +?> +--EXPECT-- +ValueError: Invalid bcrypt cost parameter specified: 3 +ValueError: Invalid bcrypt cost parameter specified: 32 +bool(true) +OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt b/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt new file mode 100644 index 000000000000..90418dcf1dc1 --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt @@ -0,0 +1,50 @@ +--TEST-- +Test password_get_info() with bcrypt-sha256 +--FILE-- + 4]))); + +// Malformed hash is reported as unknown +var_dump(password_get_info('$bcrypt-sha256$v=2,t=2b,r=12$bad-salt!!$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2')); + +echo "OK!"; +?> +--EXPECT-- +array(3) { + ["algo"]=> + string(13) "bcrypt-sha256" + ["algoName"]=> + string(13) "bcrypt-sha256" + ["options"]=> + array(1) { + ["cost"]=> + int(12) + } +} +array(3) { + ["algo"]=> + string(13) "bcrypt-sha256" + ["algoName"]=> + string(13) "bcrypt-sha256" + ["options"]=> + array(1) { + ["cost"]=> + int(4) + } +} +array(3) { + ["algo"]=> + NULL + ["algoName"]=> + string(7) "unknown" + ["options"]=> + array(0) { + } +} +OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt b/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt new file mode 100644 index 000000000000..a6a9cf3431ab --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt @@ -0,0 +1,37 @@ +--TEST-- +Test normal operation of password_hash() with bcrypt-sha256 +--FILE-- + 2 digit rounds, 22-char salt, 31-char digest) +$h = password_hash("foo", PASSWORD_BCRYPT_SHA256); +var_dump(preg_match('#^\$bcrypt-sha256\$v=2,t=2b,r=12\$[./A-Za-z0-9]{22}\$[./A-Za-z0-9]{31}$#', $h) === 1); + +// Round-trip: correct password verifies, wrong one does not +var_dump(password_verify("foo", $h)); +var_dump(password_verify("bar", $h)); + +// 1-digit cost +$h4 = password_hash("foo", PASSWORD_BCRYPT_SHA256, ["cost" => 4]); +var_dump(preg_match('#^\$bcrypt-sha256\$v=2,t=2b,r=4\$[./A-Za-z0-9]{22}\$[./A-Za-z0-9]{31}$#', $h4) === 1); +var_dump(password_verify("foo", $h4)); + +// The string ident works too +$h2 = password_hash("foo", "bcrypt-sha256"); +var_dump(password_verify("foo", $h2)); + +// New algorithm is advertised by password_algos() +var_dump(in_array("bcrypt-sha256", password_algos())); + +echo "OK!"; +?> +--EXPECT-- +bool(true) +bool(true) +bool(false) +bool(true) +bool(true) +bool(true) +bool(true) +OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt b/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt new file mode 100644 index 000000000000..42fb5e3d24fb --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt @@ -0,0 +1,25 @@ +--TEST-- +Test password_needs_rehash() with bcrypt-sha256 +--FILE-- + 10]); + +// Same cost -> no rehash +var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT_SHA256, ["cost" => 10])); +// Different cost -> rehash +var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT_SHA256, ["cost" => 12])); +// Different algorithm -> rehash +var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT)); +// Unrecognized hash -> rehash +var_dump(password_needs_rehash("", PASSWORD_BCRYPT_SHA256)); + +echo "OK!"; +?> +--EXPECT-- +bool(false) +bool(true) +bool(true) +bool(true) +OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt b/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt new file mode 100644 index 000000000000..8dccac116473 --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt @@ -0,0 +1,40 @@ +--TEST-- +Test password_verify() with bcrypt-sha256 (reference interop + edge cases) +--FILE-- + +--EXPECT-- +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +OK! From fbd1b85fd74a39a53d83a25d8a25bca5d9114e02 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 2 Oct 2026 18:21:24 +0000 Subject: [PATCH 2/9] Add test vectors from passlib Don't accept leading zeroes on rounds int. Changed bad test vectors to be more like v2 good hashes. --- ext/standard/password.c | 2 +- .../password_bcrypt_sha256_passlib.phpt | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_passlib.phpt diff --git a/ext/standard/password.c b/ext/standard/password.c index 38388b8e5585..9393659ef5ff 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -305,7 +305,7 @@ static bool php_password_bcrypt_sha256_parse(const zend_string *hash, } p = h + PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN; - if (*p < '0' || *p > '9') { + if (*p < '1' || *p > '9') { return false; } c = *p - '0'; diff --git a/ext/standard/tests/password/password_bcrypt_sha256_passlib.phpt b/ext/standard/tests/password/password_bcrypt_sha256_passlib.phpt new file mode 100644 index 000000000000..c7537dc2408c --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_passlib.phpt @@ -0,0 +1,120 @@ +--TEST-- +Test password_verify() with bcrypt-sha256 using passlib test vectors (v2 format) +--FILE-- + '$bcrypt-sha256$v=2,t=2b,r=5$E/e/2AOhqM5W/KJTFQzLce$WFPIZKtDDTriqWwlmRFfHiOTeheAZWe', + + # ascii + "password" => '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # unicode / utf8 + $upass => '$bcrypt-sha256$v=2,t=2b,r=5$.US1fQ4TQS.ZTz/uJ5Kyn.$pzzgp40k8reM1CuQb03PvE0IDPQSdV6', + + # test >72 chars is hashed correctly -- under bcrypt these hash the same. + str_repeat('abc123', 12) => '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$zu1cloESVFIOsUIo7fCEgkdHaI9SSue', + str_repeat('abc123', 12) . 'qwr' => '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$CBF9csfEdW68xv3DwE6xSULXMtqEFP.', + str_repeat('abc123', 12) . 'xyz' => '$bcrypt-sha256$v=2,t=2b,r=5$X1g1nh3g0v4h6970O68cxe$zC/1UDUG2ofEXB6Onr2vvyFzfhEOS3S', +]; + +$bad = [ + # bad char in otherwise correct hash + # \/ + '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKF!PE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # unsupported version (for this format) + '$bcrypt-sha256$v=1,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # unrecognized version + '$bcrypt-sha256$v=3,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # unrecognized bcrypt variant + '$bcrypt-sha256$v=2,t=2c,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # unsupported bcrypt variant + '$bcrypt-sha256$v=2,t=2a,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + '$bcrypt-sha256$v=2,t=2x,r=5$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # rounds zero-padded + '$bcrypt-sha256$v=2,t=2b,r=05$5Hg1DKFqPE8C2aflZ5vVoe$wOK1VFFtS8IGTrGa7.h5fs0u84qyPbS', + + # config string w/ $ added + '$bcrypt-sha256$v=2,t=2b,r=5$5Hg1DKFqPE8C2aflZ5vVoe$', +]; + +echo "# Good hashes\n"; +foreach ($good as $password => $hash) { + var_dump(password_verify($password, $hash)); + var_dump(password_verify($password . "x", $hash)); +} + +echo "# Bad hashes\n"; +foreach ($bad as $hash) { + var_dump(password_verify('password', $hash)); +} + +?> +--EXPECT-- +# Good hashes +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +# Bad hashes +bool(false) +bool(false) +bool(false) +bool(false) +bool(false) +bool(false) +bool(false) +bool(false) From 04a3a16ac549919ebc5890c2bd7b8a2ab45c88ec Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 2 Oct 2026 18:36:23 +0000 Subject: [PATCH 3/9] Small code cleanup --- ext/standard/password.c | 23 ++++++++++------------- ext/standard/php_password.h | 2 +- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 9393659ef5ff..cb48662b1fbd 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -240,7 +240,6 @@ const php_password_algo php_password_algo_bcrypt = { * SHA256 is taken from the hash extension. */ #define PHP_PASSWORD_BCRYPT_SHA256_PREFIX "$bcrypt-sha256$v=2,t=2b,r=" -#define PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN (sizeof(PHP_PASSWORD_BCRYPT_SHA256_PREFIX) - 1) static bool php_password_b64char(unsigned char c) { @@ -291,24 +290,22 @@ static void php_password_hmac_sha256(const unsigned char *key, size_t key_len, static bool php_password_bcrypt_sha256_parse(const zend_string *hash, zend_long *cost, const char **salt, const char **digest) { + if (!zend_string_starts_with_literal(hash, PHP_PASSWORD_BCRYPT_SHA256_PREFIX)) { + return false; + } + const char *h = ZSTR_VAL(hash); - const char *p; size_t len = ZSTR_LEN(hash); - size_t i; - int c; if (len < 82 || len > 83) { return false; } - if (memcmp(h, PHP_PASSWORD_BCRYPT_SHA256_PREFIX, PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN) != 0) { - return false; - } - p = h + PHP_PASSWORD_BCRYPT_SHA256_PREFIX_LEN; + const char *p = h + strlen(PHP_PASSWORD_BCRYPT_SHA256_PREFIX); if (*p < '1' || *p > '9') { return false; } - c = *p - '0'; + int c = *p - '0'; p++; if (*p >= '0' && *p <= '9') { c = c * 10 + (*p - '0'); @@ -322,7 +319,7 @@ static bool php_password_bcrypt_sha256_parse(const zend_string *hash, if (len - (size_t)(p - h) != 54) { return false; } - for (i = 0; i < 22; i++) { + for (size_t i = 0; i < 22; i++) { if (!php_password_b64char((unsigned char) p[i])) { return false; } @@ -330,7 +327,7 @@ static bool php_password_bcrypt_sha256_parse(const zend_string *hash, if (p[22] != '$') { return false; } - for (i = 0; i < 31; i++) { + for (size_t i = 0; i < 31; i++) { if (!php_password_b64char((unsigned char) p[23 + i])) { return false; } @@ -371,7 +368,7 @@ static bool php_password_bcrypt_sha256_needs_rehash(const zend_string *hash, zen if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { return true; } - if (options && (znew_cost = zend_hash_str_find(options, "cost", sizeof("cost") - 1)) != NULL) { + if (options && (znew_cost = zend_hash_str_find(options, "cost", strlen("cost"))) != NULL) { new_cost = zval_get_long(znew_cost); } return cost != new_cost; @@ -388,7 +385,7 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, char out[84]; int out_len; - if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost") - 1)) != NULL) { + if (options && (zcost = zend_hash_str_find(options, "cost", strlen("cost"))) != NULL) { cost = zval_get_long(zcost); } if (cost < 4 || cost > 31) { diff --git a/ext/standard/php_password.h b/ext/standard/php_password.h index adac8ef2e30d..f682511ffaf3 100644 --- a/ext/standard/php_password.h +++ b/ext/standard/php_password.h @@ -21,7 +21,7 @@ PHP_MSHUTDOWN_FUNCTION(password); #define PHP_PASSWORD_DEFAULT PHP_PASSWORD_BCRYPT #define PHP_PASSWORD_BCRYPT_COST 12 -#define PHP_PASSWORD_BCRYPT_SHA256_COST 12 +#define PHP_PASSWORD_BCRYPT_SHA256_COST PHP_PASSWORD_BCRYPT_COST #ifdef HAVE_ARGON2LIB /** From 0c9f8d661ebefac38ce486c50121d490cd705756 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 2 Oct 2026 19:07:34 +0000 Subject: [PATCH 4/9] Speed up tests --- .../password_bcrypt_sha256_errors.phpt | 4 --- .../password_bcrypt_sha256_get_info.phpt | 2 +- .../password/password_bcrypt_sha256_hash.phpt | 20 ++--------- .../password_bcrypt_sha256_hash_slow.phpt | 21 ++++++++++++ .../password_bcrypt_sha256_needs_rehash.phpt | 8 ++--- .../password_bcrypt_sha256_verify.phpt | 33 +++++++++++++------ 6 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 ext/standard/tests/password/password_bcrypt_sha256_hash_slow.phpt diff --git a/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt b/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt index 3fe80bb2c95b..987903c00806 100644 --- a/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt +++ b/ext/standard/tests/password/password_bcrypt_sha256_errors.phpt @@ -15,13 +15,9 @@ try { echo $e::class, ': ', $e->getMessage(), "\n"; } -// Unlike bcrypt, a NUL byte is allowed: the HMAC pre-hash removes the quirk. -var_dump(password_verify("foo\x00bar", password_hash("foo\x00bar", PASSWORD_BCRYPT_SHA256))); - echo "OK!"; ?> --EXPECT-- ValueError: Invalid bcrypt cost parameter specified: 3 ValueError: Invalid bcrypt cost parameter specified: 32 -bool(true) OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt b/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt index 90418dcf1dc1..fe1e7f9d8696 100644 --- a/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt +++ b/ext/standard/tests/password/password_bcrypt_sha256_get_info.phpt @@ -5,7 +5,7 @@ Test password_get_info() with bcrypt-sha256 //-=-=-=- // Default cost (12) -var_dump(password_get_info(password_hash("foo", PASSWORD_BCRYPT_SHA256))); +var_dump(password_get_info('$bcrypt-sha256$v=2,t=2b,r=12$Ge8FmPKsQyGbz2aS6.c.o4$F.trVwbr/wDlyxMl/H8x1Pj.yiLhmCu')); // 1-digit cost var_dump(password_get_info(password_hash("foo", PASSWORD_BCRYPT_SHA256, ["cost" => 4]))); diff --git a/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt b/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt index a6a9cf3431ab..b1f28db3ea3d 100644 --- a/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt +++ b/ext/standard/tests/password/password_bcrypt_sha256_hash.phpt @@ -2,27 +2,14 @@ Test normal operation of password_hash() with bcrypt-sha256 --FILE-- 2 digit rounds, 22-char salt, 31-char digest) -$h = password_hash("foo", PASSWORD_BCRYPT_SHA256); -var_dump(preg_match('#^\$bcrypt-sha256\$v=2,t=2b,r=12\$[./A-Za-z0-9]{22}\$[./A-Za-z0-9]{31}$#', $h) === 1); - -// Round-trip: correct password verifies, wrong one does not -var_dump(password_verify("foo", $h)); -var_dump(password_verify("bar", $h)); - -// 1-digit cost $h4 = password_hash("foo", PASSWORD_BCRYPT_SHA256, ["cost" => 4]); var_dump(preg_match('#^\$bcrypt-sha256\$v=2,t=2b,r=4\$[./A-Za-z0-9]{22}\$[./A-Za-z0-9]{31}$#', $h4) === 1); var_dump(password_verify("foo", $h4)); - -// The string ident works too -$h2 = password_hash("foo", "bcrypt-sha256"); -var_dump(password_verify("foo", $h2)); +var_dump(password_verify("bar", $h4)); // New algorithm is advertised by password_algos() -var_dump(in_array("bcrypt-sha256", password_algos())); +var_dump(in_array(PASSWORD_BCRYPT_SHA256, password_algos())); echo "OK!"; ?> @@ -31,7 +18,4 @@ bool(true) bool(true) bool(false) bool(true) -bool(true) -bool(true) -bool(true) OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_hash_slow.phpt b/ext/standard/tests/password/password_bcrypt_sha256_hash_slow.phpt new file mode 100644 index 000000000000..9831e9d1cfc7 --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_sha256_hash_slow.phpt @@ -0,0 +1,21 @@ +--TEST-- +Test normal operation of password_hash() with bcrypt-sha256 +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(false) +OK! diff --git a/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt b/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt index 42fb5e3d24fb..ebb18d11769c 100644 --- a/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt +++ b/ext/standard/tests/password/password_bcrypt_sha256_needs_rehash.phpt @@ -4,14 +4,14 @@ Test password_needs_rehash() with bcrypt-sha256 10]); +$h5 = password_hash("foo", PASSWORD_BCRYPT_SHA256, ["cost" => 5]); // Same cost -> no rehash -var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT_SHA256, ["cost" => 10])); +var_dump(password_needs_rehash($h5, PASSWORD_BCRYPT_SHA256, ["cost" => 5])); // Different cost -> rehash -var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT_SHA256, ["cost" => 12])); +var_dump(password_needs_rehash($h5, PASSWORD_BCRYPT_SHA256, ["cost" => 8])); // Different algorithm -> rehash -var_dump(password_needs_rehash($h10, PASSWORD_BCRYPT)); +var_dump(password_needs_rehash($h5, PASSWORD_BCRYPT)); // Unrecognized hash -> rehash var_dump(password_needs_rehash("", PASSWORD_BCRYPT_SHA256)); diff --git a/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt b/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt index 8dccac116473..8ccd709e8283 100644 --- a/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt +++ b/ext/standard/tests/password/password_bcrypt_sha256_verify.phpt @@ -2,25 +2,30 @@ Test password_verify() with bcrypt-sha256 (reference interop + edge cases) --FILE-- 5]); var_dump(password_verify($long, $hl)); // true var_dump(password_verify(str_repeat("a", 199), $hl)); // false (no 72-byte truncation) // A NUL byte in the password is allowed (the pre-hash neutralizes the NUL quirk). $nul = "foo\x00bar"; -$hn = password_hash($nul, PASSWORD_BCRYPT_SHA256); +$hn = password_hash($nul, PASSWORD_BCRYPT_SHA256, ['cost' => 5]); var_dump(password_verify($nul, $hn)); // true var_dump(password_verify("foo\x00baz", $hn)); // false @@ -36,5 +41,13 @@ bool(true) bool(false) bool(true) bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) bool(false) OK! From 9f82e6bdaea487f68a3d8797d8eced9fd761e24c Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sat, 3 Oct 2026 12:09:40 +0000 Subject: [PATCH 5/9] Use php_safe_bcmp, zend_strpprintf --- ext/standard/password.c | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index cb48662b1fbd..093b141f7275 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -424,15 +424,11 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, /* Relabel the $2y$ result into the bcrypt-sha256 format. The digest is the * last 31 characters of the 60-byte bcrypt output. */ - out_len = snprintf(out, sizeof(out), "$bcrypt-sha256$v=2,t=2b,r=%" ZEND_LONG_FMT_SPEC "$%s$%s", + result = zend_strpprintf(0, "$bcrypt-sha256$v=2,t=2b,r=%" ZEND_LONG_FMT_SPEC "$%s$%s", cost, ZSTR_VAL(salt), ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31)); zend_string_release_ex(salt, 0); zend_string_free(raw); - if (out_len <= 0 || (size_t) out_len >= sizeof(out)) { - return NULL; - } - result = zend_string_init(out, (size_t) out_len, 0); return result; } @@ -442,11 +438,9 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const const char *salt, *digest; unsigned char mac[32]; zend_string *key, *setting, *raw; + zend_string *raw_digest, *stored_digest; char setting_prefix[16]; size_t prefix_len; - const volatile unsigned char *ra, *rb; - size_t i; - int r; bool ret; if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { @@ -478,15 +472,15 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const * salt into 22 base64 chars, and the 4 unused padding bits can change the last * character), but both encodings decode to the same salt bytes, so the digest * is identical. */ - ra = (const volatile unsigned char *)(ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31)); - rb = (const volatile unsigned char *)digest; - r = 0; - for (i = 0; i < 31; i++) { - r |= ra[i] ^ rb[i]; - } - ret = (r == 0); + raw_digest = zend_string_init(ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31), 31, 0); + stored_digest = zend_string_init(digest, 31, 0); + + ret = (php_safe_bcmp(raw_digest, stored_digest) == 0); + zend_string_release_ex(raw_digest, 0); + zend_string_release_ex(stored_digest, 0); zend_string_free(raw); + return ret; } From ddda5c6577308a9da7f842fd4b4d58c30e595022 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sat, 3 Oct 2026 12:15:10 +0000 Subject: [PATCH 6/9] Reduce scope of variables --- ext/standard/password.c | 134 ++++++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 59 deletions(-) diff --git a/ext/standard/password.c b/ext/standard/password.c index 093b141f7275..1bebe77337ac 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -294,31 +294,36 @@ static bool php_password_bcrypt_sha256_parse(const zend_string *hash, return false; } - const char *h = ZSTR_VAL(hash); - size_t len = ZSTR_LEN(hash); + const char *p; + int c; + { + const char *h = ZSTR_VAL(hash); + size_t len = ZSTR_LEN(hash); - if (len < 82 || len > 83) { - return false; - } - const char *p = h + strlen(PHP_PASSWORD_BCRYPT_SHA256_PREFIX); + if (len < 82 || len > 83) { + return false; + } + p = h + strlen(PHP_PASSWORD_BCRYPT_SHA256_PREFIX); - if (*p < '1' || *p > '9') { - return false; - } - int c = *p - '0'; - p++; - if (*p >= '0' && *p <= '9') { - c = c * 10 + (*p - '0'); + if (*p < '1' || *p > '9') { + return false; + } + c = *p - '0'; + p++; + if (*p >= '0' && *p <= '9') { + c = c * 10 + (*p - '0'); + p++; + } + if (c < 4 || c > 31 || *p != '$') { + return false; + } p++; - } - if (c < 4 || c > 31 || *p != '$') { - return false; - } - p++; - if (len - (size_t)(p - h) != 54) { - return false; + if (len - (size_t)(p - h) != 54) { + return false; + } } + for (size_t i = 0; i < 22; i++) { if (!php_password_b64char((unsigned char) p[i])) { return false; @@ -362,38 +367,39 @@ static bool php_password_bcrypt_sha256_needs_rehash(const zend_string *hash, zen { zend_long cost; const char *salt, *digest; - zval *znew_cost; - zend_long new_cost = PHP_PASSWORD_BCRYPT_SHA256_COST; if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { return true; } - if (options && (znew_cost = zend_hash_str_find(options, "cost", strlen("cost"))) != NULL) { - new_cost = zval_get_long(znew_cost); + + zend_long new_cost = PHP_PASSWORD_BCRYPT_SHA256_COST; + if (options) { + zval *znew_cost = zend_hash_str_find(options, "cost", strlen("cost")); + if (znew_cost != NULL) { + new_cost = zval_get_long(znew_cost); + } } + return cost != new_cost; } static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, zend_array *options) { - zval *zcost; zend_long cost = PHP_PASSWORD_BCRYPT_SHA256_COST; - zend_string *salt, *key, *setting, *raw, *result = NULL; - unsigned char mac[32]; - char setting_prefix[16]; - size_t prefix_len; - char out[84]; - int out_len; - - if (options && (zcost = zend_hash_str_find(options, "cost", strlen("cost"))) != NULL) { - cost = zval_get_long(zcost); + + if (options) { + zval *zcost = zend_hash_str_find(options, "cost", strlen("cost")); + if (zcost != NULL) { + cost = zval_get_long(zcost); + } } if (cost < 4 || cost > 31) { zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); return NULL; } - if (!(salt = php_password_get_salt(NULL, 22, options))) { + zend_string *salt = php_password_get_salt(NULL, 22, options); + if (!salt) { return NULL; } @@ -401,16 +407,24 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, * base64-encode the 32-byte digest. The resulting 44-byte string is what is * handed to bcrypt, so any NUL bytes or length beyond 72 in the original * password are neutralized. */ - php_password_hmac_sha256((const unsigned char *) ZSTR_VAL(salt), ZSTR_LEN(salt), - (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); + zend_string *key; + { + unsigned char mac[32]; + php_password_hmac_sha256((const unsigned char *) ZSTR_VAL(salt), ZSTR_LEN(salt), + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); - key = php_base64_encode(mac, sizeof(mac)); - ZEND_SECURE_ZERO(mac, sizeof(mac)); + key = php_base64_encode(mac, sizeof(mac)); + ZEND_SECURE_ZERO(mac, sizeof(mac)); + } - prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); - setting = zend_string_concat2(setting_prefix, prefix_len, ZSTR_VAL(salt), ZSTR_LEN(salt)); + zend_string *setting; + { + char setting_prefix[16]; + size_t prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); + setting = zend_string_concat2(setting_prefix, prefix_len, ZSTR_VAL(salt), ZSTR_LEN(salt)); + } - raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); + zend_string *raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); zend_string_release_ex(setting, 0); zend_string_release_ex(key, 0); @@ -424,7 +438,7 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, /* Relabel the $2y$ result into the bcrypt-sha256 format. The digest is the * last 31 characters of the 60-byte bcrypt output. */ - result = zend_strpprintf(0, "$bcrypt-sha256$v=2,t=2b,r=%" ZEND_LONG_FMT_SPEC "$%s$%s", + zend_string *result = zend_strpprintf(0, "$bcrypt-sha256$v=2,t=2b,r=%" ZEND_LONG_FMT_SPEC "$%s$%s", cost, ZSTR_VAL(salt), ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31)); zend_string_release_ex(salt, 0); zend_string_free(raw); @@ -436,27 +450,29 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const { zend_long cost; const char *salt, *digest; - unsigned char mac[32]; - zend_string *key, *setting, *raw; - zend_string *raw_digest, *stored_digest; - char setting_prefix[16]; - size_t prefix_len; - bool ret; if (!php_password_bcrypt_sha256_parse(hash, &cost, &salt, &digest)) { return false; } - php_password_hmac_sha256((const unsigned char *) salt, 22, - (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); + zend_string *key; + { + unsigned char mac[32]; + php_password_hmac_sha256((const unsigned char *) salt, 22, + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); - key = php_base64_encode(mac, sizeof(mac)); - ZEND_SECURE_ZERO(mac, sizeof(mac)); + key = php_base64_encode(mac, sizeof(mac)); + ZEND_SECURE_ZERO(mac, sizeof(mac)); + } - prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); - setting = zend_string_concat2(setting_prefix, prefix_len, salt, 22); + zend_string *setting; + { + char setting_prefix[16]; + size_t prefix_len = snprintf(setting_prefix, sizeof(setting_prefix), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); + setting = zend_string_concat2(setting_prefix, prefix_len, salt, 22); + } - raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); + zend_string *raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); zend_string_release_ex(setting, 0); zend_string_release_ex(key, 0); @@ -472,10 +488,10 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const * salt into 22 base64 chars, and the 4 unused padding bits can change the last * character), but both encodings decode to the same salt bytes, so the digest * is identical. */ - raw_digest = zend_string_init(ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31), 31, 0); - stored_digest = zend_string_init(digest, 31, 0); + zend_string *raw_digest = zend_string_init(ZSTR_VAL(raw) + (ZSTR_LEN(raw) - 31), 31, 0); + zend_string *stored_digest = zend_string_init(digest, 31, 0); - ret = (php_safe_bcmp(raw_digest, stored_digest) == 0); + bool ret = (php_safe_bcmp(raw_digest, stored_digest) == 0); zend_string_release_ex(raw_digest, 0); zend_string_release_ex(stored_digest, 0); From 1061a01606b11be5f39e51ac5ef195fe8e6a921f Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sat, 3 Oct 2026 12:24:28 +0000 Subject: [PATCH 7/9] Zero HMAC key --- ext/standard/password.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/standard/password.c b/ext/standard/password.c index 1bebe77337ac..b9f21dd905c7 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -426,6 +426,8 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, zend_string *raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); zend_string_release_ex(setting, 0); + + ZEND_SECURE_ZERO(ZSTR_VAL(key), ZSTR_LEN(key)); zend_string_release_ex(key, 0); if (!raw || ZSTR_LEN(raw) < 60) { @@ -474,6 +476,8 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const zend_string *raw = php_crypt(ZSTR_VAL(key), (int) ZSTR_LEN(key), ZSTR_VAL(setting), (int) ZSTR_LEN(setting), 1); zend_string_release_ex(setting, 0); + + ZEND_SECURE_ZERO(ZSTR_VAL(key), ZSTR_LEN(key)); zend_string_release_ex(key, 0); if (!raw || ZSTR_LEN(raw) < 60) { From e1b7f137b7dfc5b5f676ed2e0b1ca10240bb0930 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sat, 3 Oct 2026 12:42:00 +0000 Subject: [PATCH 8/9] Zero buffers after computing HMAC --- ext/standard/password.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/standard/password.c b/ext/standard/password.c index b9f21dd905c7..4e5be81f611e 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -281,6 +281,10 @@ static void php_password_hmac_sha256(const unsigned char *key, size_t key_len, PHP_SHA256Update(&ctx, k_opad, 64); PHP_SHA256Update(&ctx, key_hash, 32); PHP_SHA256Final(digest, &ctx); + + ZEND_SECURE_ZERO(k_ipad, sizeof(k_ipad)); + ZEND_SECURE_ZERO(k_opad, sizeof(k_opad)); + ZEND_SECURE_ZERO(key_hash, sizeof(key_hash)); } /* Validate a bcrypt-sha256 hash and, on success, extract its cost, salt and From 1ead882332890ea7c4abf7fcfd567519e92e53fc Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sat, 3 Oct 2026 13:33:10 +0000 Subject: [PATCH 9/9] Move HMAC hashing logic to hash.c --- ext/hash/hash.c | 52 +++++++++++++++++++++++++++-------------- ext/hash/php_hash.h | 2 ++ ext/hash/php_hash_sha.h | 2 ++ ext/standard/password.c | 44 +++------------------------------- 4 files changed, 41 insertions(+), 59 deletions(-) diff --git a/ext/hash/hash.c b/ext/hash/hash.c index a950d824700f..ad0ea14cb3c8 100644 --- a/ext/hash/hash.c +++ b/ext/hash/hash.c @@ -487,13 +487,32 @@ static inline void php_hash_hmac_round(unsigned char *final, const php_hash_ops ops->hash_final(final, context); } +/* Computes HMAC(key, data) using the given hash algorithm and writes the + * result into `digest`, which must be at least ops->digest_size bytes. + * Intended for internal (C-level) use by other extensions; avoids the + * zval/zend_string overhead of the userland hash_hmac() API. */ +PHPAPI void php_hash_hmac(const php_hash_ops *ops, const unsigned char *key, size_t key_len, + const unsigned char *data, size_t data_len, unsigned char *digest) +{ + void *context = php_hash_alloc_context(ops); + unsigned char *K = emalloc(ops->block_size); + + php_hash_hmac_prep_key(K, ops, context, key, key_len); + php_hash_hmac_round(digest, ops, context, K, data, data_len); + + php_hash_string_xor_char(K, K, 0x6A, ops->block_size); + php_hash_hmac_round(digest, ops, context, K, digest, ops->digest_size); + + ZEND_SECURE_ZERO(K, ops->block_size); + efree(K); + php_hash_free_context(ops, context); +} + static void php_hash_do_hash_hmac( zval *return_value, zend_string *algo, char *data, size_t data_len, char *key, size_t key_len, bool raw_output, bool isfilename ) /* {{{ */ { zend_string *digest; - unsigned char *K; const php_hash_ops *ops; - void *context; php_stream *stream = NULL; ops = php_hash_fetch_ops(algo); @@ -514,16 +533,16 @@ static void php_hash_do_hash_hmac( } } - context = php_hash_alloc_context(ops); - - K = emalloc(ops->block_size); digest = zend_string_alloc(ops->digest_size, 0); - php_hash_hmac_prep_key(K, ops, context, (unsigned char *) key, key_len); - if (isfilename) { + void *context = php_hash_alloc_context(ops); + unsigned char *K = emalloc(ops->block_size); char buf[1024]; ssize_t n; + + php_hash_hmac_prep_key(K, ops, context, (unsigned char *) key, key_len); + ops->hash_init(context, NULL); ops->hash_update(context, K, ops->block_size); while ((n = php_stream_read(stream, buf, sizeof(buf))) > 0) { @@ -536,20 +555,17 @@ static void php_hash_do_hash_hmac( zend_string_efree(digest); RETURN_FALSE; } - ops->hash_final((unsigned char *) ZSTR_VAL(digest), context); - } else { - php_hash_hmac_round((unsigned char *) ZSTR_VAL(digest), ops, context, K, (unsigned char *) data, data_len); - } - - php_hash_string_xor_char(K, K, 0x6A, ops->block_size); - php_hash_hmac_round((unsigned char *) ZSTR_VAL(digest), ops, context, K, (unsigned char *) ZSTR_VAL(digest), ops->digest_size); + php_hash_string_xor_char(K, K, 0x6A, ops->block_size); + php_hash_hmac_round((unsigned char *) ZSTR_VAL(digest), ops, context, K, (unsigned char *) ZSTR_VAL(digest), ops->digest_size); - /* Zero the key */ - ZEND_SECURE_ZERO(K, ops->block_size); - efree(K); - php_hash_free_context(ops, context); + ZEND_SECURE_ZERO(K, ops->block_size); + efree(K); + php_hash_free_context(ops, context); + } else { + php_hash_hmac(ops, (unsigned char *) key, key_len, (unsigned char *) data, data_len, (unsigned char *) ZSTR_VAL(digest)); + } if (raw_output) { ZSTR_VAL(digest)[ops->digest_size] = 0; diff --git a/ext/hash/php_hash.h b/ext/hash/php_hash.h index 69902f1484ed..a6656acc1bfa 100644 --- a/ext/hash/php_hash.h +++ b/ext/hash/php_hash.h @@ -159,6 +159,8 @@ PHP_HASH_API hash_spec_result php_hash_serialize(const php_hashcontext_object *c PHP_HASH_API hash_spec_result php_hash_unserialize(php_hashcontext_object *context, zend_long magic, const zval *zv); PHP_HASH_API hash_spec_result php_hash_serialize_spec(const php_hashcontext_object *context, zval *zv, const char *spec); PHP_HASH_API hash_spec_result php_hash_unserialize_spec(php_hashcontext_object *hash, const zval *zv, const char *spec); +PHP_HASH_API void php_hash_hmac(const php_hash_ops *ops, const unsigned char *key, size_t key_len, + const unsigned char *data, size_t data_len, unsigned char *digest); static inline void *php_hash_alloc_context(const php_hash_ops *ops) { /* Zero out context memory so serialization doesn't expose internals */ diff --git a/ext/hash/php_hash_sha.h b/ext/hash/php_hash_sha.h index e2c6eac30b4c..5dd39d14c006 100644 --- a/ext/hash/php_hash_sha.h +++ b/ext/hash/php_hash_sha.h @@ -103,4 +103,6 @@ PHP_HASH_API void PHP_SHA512_224InitArgs(PHP_SHA512_CTX *, ZEND_ATTRIBUTE_UNUSED #define PHP_SHA512_224Update PHP_SHA512Update PHP_HASH_API void PHP_SHA512_224Final(unsigned char[28], PHP_SHA512_CTX *); +extern PHP_HASH_API const php_hash_ops php_hash_sha256_ops; + #endif /* PHP_HASH_SHA_H */ diff --git a/ext/standard/password.c b/ext/standard/password.c index 4e5be81f611e..f2790c26a185 100644 --- a/ext/standard/password.c +++ b/ext/standard/password.c @@ -24,7 +24,7 @@ #include "zend_interfaces.h" #include "info.h" #include "ext/random/php_random_csprng.h" -#include "ext/hash/php_hash.h" /* Needed for PHP_HASH_API in ext/hash/php_hash_sha.h */ +#include "ext/hash/php_hash.h" #include "ext/hash/php_hash_sha.h" #include "password_arginfo.h" #ifdef HAVE_ARGON2LIB @@ -249,44 +249,6 @@ static bool php_password_b64char(unsigned char c) (c >= '0' && c <= '9'); } -static void php_password_hmac_sha256(const unsigned char *key, size_t key_len, - const unsigned char *msg, size_t msg_len, unsigned char digest[32]) -{ - PHP_SHA256_CTX ctx; - unsigned char k_ipad[64], k_opad[64], key_hash[32]; - size_t i; - - /* If the key is longer than the block size (64), hash it first. Our key is - * always the 22-byte salt, so this branch is never taken in practice. */ - if (key_len > 64) { - PHP_SHA256Init(&ctx); - PHP_SHA256Update(&ctx, key, key_len); - PHP_SHA256Final(key_hash, &ctx); - key = key_hash; - key_len = 32; - } - - for (i = 0; i < 64; i++) { - unsigned char k = (i < key_len) ? key[i] : 0; - k_ipad[i] = k ^ 0x36; - k_opad[i] = k ^ 0x5c; - } - - PHP_SHA256Init(&ctx); - PHP_SHA256Update(&ctx, k_ipad, 64); - PHP_SHA256Update(&ctx, msg, msg_len); - PHP_SHA256Final(key_hash, &ctx); - - PHP_SHA256Init(&ctx); - PHP_SHA256Update(&ctx, k_opad, 64); - PHP_SHA256Update(&ctx, key_hash, 32); - PHP_SHA256Final(digest, &ctx); - - ZEND_SECURE_ZERO(k_ipad, sizeof(k_ipad)); - ZEND_SECURE_ZERO(k_opad, sizeof(k_opad)); - ZEND_SECURE_ZERO(key_hash, sizeof(key_hash)); -} - /* Validate a bcrypt-sha256 hash and, on success, extract its cost, salt and * digest. Layout: * $bcrypt-sha256$v=2,t=2b,r=$$ (82 or 83 bytes) @@ -414,7 +376,7 @@ static zend_string *php_password_bcrypt_sha256_hash(const zend_string *password, zend_string *key; { unsigned char mac[32]; - php_password_hmac_sha256((const unsigned char *) ZSTR_VAL(salt), ZSTR_LEN(salt), + php_hash_hmac(&php_hash_sha256_ops, (const unsigned char *) ZSTR_VAL(salt), ZSTR_LEN(salt), (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); key = php_base64_encode(mac, sizeof(mac)); @@ -464,7 +426,7 @@ static bool php_password_bcrypt_sha256_verify(const zend_string *password, const zend_string *key; { unsigned char mac[32]; - php_password_hmac_sha256((const unsigned char *) salt, 22, + php_hash_hmac(&php_hash_sha256_ops, (const unsigned char *) salt, 22, (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), mac); key = php_base64_encode(mac, sizeof(mac));