diff --git a/ext/hash/hash.c b/ext/hash/hash.c index a950d824700f..d3dd8fe92ff0 100644 --- a/ext/hash/hash.c +++ b/ext/hash/hash.c @@ -487,6 +487,35 @@ static inline void php_hash_hmac_round(unsigned char *final, const php_hash_ops ops->hash_final(final, context); } +PHP_HASH_API void php_hash_hmac( + const char *algo, const size_t algo_len, + const unsigned char *data, const size_t data_len, + const unsigned char *key, const size_t key_len, + unsigned char *output, const size_t output_len +) { + const php_hash_ops *ops = zend_hash_str_find_ptr(&php_hash_hashtable, algo, algo_len); + ZEND_ASSERT(ops && ops->is_crypto); + void *ctx = php_hash_alloc_context(ops); + + ALLOCA_FLAG(use_heap_key); + unsigned char *K = do_alloca(ops->block_size, use_heap_key); + ALLOCA_FLAG(use_heap_digest); + unsigned char *digest = do_alloca(ops->digest_size, use_heap_digest); + + php_hash_hmac_prep_key(K, ops, ctx, key, key_len); + php_hash_hmac_round(digest, ops, ctx, K, data, data_len); + php_hash_string_xor_char(K, K, 0x6A, ops->block_size); + php_hash_hmac_round(digest, ops, ctx, K, digest, ops->digest_size); + php_hash_free_context(ops, ctx); + + memcpy(output, digest, output_len < ops->digest_size ? output_len : ops->digest_size); + + ZEND_SECURE_ZERO(K, ops->block_size); + free_alloca(K, use_heap_key); + ZEND_SECURE_ZERO(digest, ops->digest_size); + free_alloca(digest, use_heap_digest); +} + 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 ) /* {{{ */ { diff --git a/ext/hash/hash_sha3.c b/ext/hash/hash_sha3.c index 3f5d966820cd..654210136e90 100644 --- a/ext/hash/hash_sha3.c +++ b/ext/hash/hash_sha3.c @@ -351,3 +351,80 @@ DECLARE_SHA3_OPS(384); DECLARE_SHA3_OPS(512); #undef DECLARE_SHA3_OPS + +static size_t php_hash_sha3_left_encode(uint8_t *out, uint64_t val) +{ + uint8_t bytes[8]; + size_t num_bytes = 0; + + if (val == 0) { + bytes[0] = 0; + num_bytes = 1; + } else { + while (val > 0) { + bytes[num_bytes++] = (uint8_t)(val & 0xFF); + val >>= 8; + } + } + + out[0] = (uint8_t)num_bytes; + for (size_t i = 0; i < num_bytes; i++) { + out[1 + i] = bytes[num_bytes - 1 - i]; + } + return 1 + num_bytes; +} + +PHP_HASH_API void php_hash_cshake256( + const unsigned char *data, size_t data_len, + const unsigned char *custom, size_t custom_len, + unsigned char *output, size_t out_len +) { + PHP_SHA3_512_CTX ctx; + uint8_t header_buf[32]; + size_t pos = 0; + const size_t rate = 136; + + /* cSHAKE256 is basically SHA3-512 with a few adjusted params: + * rate = 136 bytes, capacity = 512 bits, 0 output size, 0x04 suffix + * The slow path has to account for that differently, but it works. */ +#ifdef HAVE_SLOW_HASH3 + PHP_SHA3_Init(&ctx, 512); +# define CSHAKE_UPDATE(ctx, buf, len) PHP_SHA3_Update((ctx), (buf), (len), rate) +#else + Keccak_HashInstance *hash = (Keccak_HashInstance *)&ctx; + Keccak_HashInitialize(hash, rate * 8, 512, 0, 0x04); +# define CSHAKE_UPDATE(ctx, buf, len) PHP_SHA3512Update((ctx), (buf), (len)) +#endif + + /* NIST SP800-185 header metadata */ + pos += php_hash_sha3_left_encode(&header_buf[pos], rate); + pos += php_hash_sha3_left_encode(&header_buf[pos], 0); // reserved + pos += php_hash_sha3_left_encode(&header_buf[pos], (uint64_t)custom_len * 8); + + CSHAKE_UPDATE(&ctx, header_buf, pos); + if (custom_len > 0) { + CSHAKE_UPDATE(&ctx, custom, custom_len); + } + + size_t remainder = (pos + custom_len) % rate; + if (remainder > 0) { + size_t pad_len = rate - remainder; + uint8_t zero_pad[rate]; + memset(zero_pad, 0, sizeof(zero_pad)); + CSHAKE_UPDATE(&ctx, zero_pad, pad_len); + } + + if (data_len > 0) { + CSHAKE_UPDATE(&ctx, data, data_len); + } + +#ifdef HAVE_SLOW_HASH3 + /* Hack for 0x04 when PHP_SHA3_Final does ctx->state[ctx=>post++] ^= 0x06 */ + ctx.state[ctx.pos] ^= 0x02; + PHP_SHA3_Final(output, &ctx, rate, out_len); +#else + Keccak_HashFinal(hash, NULL); + Keccak_HashSqueeze(hash, output, out_len * 8); +#endif +#undef CSHAKE_UPDATE +} diff --git a/ext/hash/php_hash.h b/ext/hash/php_hash.h index 69902f1484ed..13286c005914 100644 --- a/ext/hash/php_hash.h +++ b/ext/hash/php_hash.h @@ -159,6 +159,12 @@ 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 char *algo, const size_t algo_len, + const unsigned char *data, const size_t data_len, + const unsigned char *key, const size_t key_len, + unsigned char *output, const size_t output_len +); 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_sha3.h b/ext/hash/php_hash_sha3.h index 2692fd5de766..2538f0327066 100644 --- a/ext/hash/php_hash_sha3.h +++ b/ext/hash/php_hash_sha3.h @@ -48,4 +48,12 @@ PHP_HASH_API void PHP_SHA3512Init(PHP_SHA3_512_CTX*, ZEND_ATTRIBUTE_UNUSED HashT PHP_HASH_API void PHP_SHA3512Update(PHP_SHA3_512_CTX*, const unsigned char*, size_t); PHP_HASH_API void PHP_SAH3512Final(unsigned char[32], PHP_SHA3_512_CTX*); +/* TODO: Decide whether this is appropriate; kept minimal here to avoid scope creep, + * but userspace-exposed cSHAKE makes sense and likely looks different. */ +PHP_HASH_API void php_hash_cshake256( + const unsigned char *data, size_t data_len, + const unsigned char *custom, size_t custom_len, + unsigned char *output, size_t out_len +); + #endif diff --git a/ext/standard/config.m4 b/ext/standard/config.m4 index 7edfcf1da1f5..17fac314ffc0 100644 --- a/ext/standard/config.m4 +++ b/ext/standard/config.m4 @@ -456,6 +456,6 @@ PHP_NEW_EXTENSION([standard], m4_normalize([ [no]) 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 21e483bc344c..e08cec3c37d8 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" +#include "ext/hash/php_hash_sha3.h" #include "password_arginfo.h" #ifdef HAVE_ARGON2LIB #include "argon2.h" @@ -44,97 +46,149 @@ void php_password_algo_unregister(const char *ident) { zend_hash_str_del(&php_password_algos, ident, strlen(ident)); } -static int php_password_salt_to64(const char *str, const size_t str_len, const size_t out_len, char *ret) /* {{{ */ +static bool php_password_make_salt(unsigned char *salt, size_t length) /* {{{ */ { - size_t pos = 0; - zend_string *buffer; - if ((int) str_len < 0) { - return FAILURE; + /* 64 ought to be enough for anybody */ + if (length > 64) { + zend_value_error("Length is too large to safely generate"); + return false; } - buffer = php_base64_encode((unsigned char*) str, str_len); - if (ZSTR_LEN(buffer) < out_len) { - /* Too short of an encoded string generated */ - zend_string_release_ex(buffer, 0); - return FAILURE; + + if (FAILURE == php_random_bytes_throw(salt, length)) { + zend_value_error("Unable to generate salt"); + return false; + } + + return true; +} +/* }}} */ + +/* bcrypt implementation */ +#define PHP_BCRYPT_SALT_SIZE 16 + +static bool php_password_b64_from_bcrypt(const char *in, char *out, const size_t len) /* }}} */ +{ + /* If it doesn't match bcrypt hash size, something's wrong. + * Salt cannot be converted back, because bcrypt discards the 128th bit */ + if (len != 31) { + zend_value_error("Unexpected bcrypt size encountered: " ZEND_LONG_FMT, len); + return false; } - for (pos = 0; pos < out_len; pos++) { - if (ZSTR_VAL(buffer)[pos] == '+') { - ret[pos] = '.'; - } else if (ZSTR_VAL(buffer)[pos] == '=') { - zend_string_free(buffer); - return FAILURE; + + for (size_t i = 0; i < len; i++) { + char c = in[i]; + /* bcrypt: ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + * phcb64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + * This is rather cryptic, but simpler than having tables. */ + if (c == '.' || c == '/') { + out[i] = c + 19; + } else if ((c >= 'A' && c <= 'X') || (c >= 'a' && c <= 'x') || (c >= '0' && c <= '7')) { + out[i] = c + 2; + } else if (c == 'Y' || c == 'Z') { + out[i] = c + 8; + } else if (c == 'y' || c == 'z') { + out[i] = c - 73; + } else if (c == '8') { + out[i] = '+'; + } else if (c == '9') { + out[i] = '/'; } else { - ret[pos] = ZSTR_VAL(buffer)[pos]; + zend_value_error("Invalid bcrypt base64 character encountered: %c", c); + return false; } } - zend_string_free(buffer); - return SUCCESS; + + out[len] = 0; + return true; } /* }}} */ -static zend_string* php_password_make_salt(size_t length) /* {{{ */ +static bool php_password_bcrypt_from_b64(const char *in, char *out, const size_t len) /* }}} */ { - zend_string *ret, *buffer; - - if (length > (INT_MAX / 3)) { - zend_value_error("Length is too large to safely generate"); - return NULL; + /* If it doesn't match bcrypt salt/hash size, something's wrong */ + if (len != 22 && len != 31) { + zend_value_error("Unexpected bcrypt size encountered: " ZEND_LONG_FMT, len); + return false; } - buffer = zend_string_alloc(length * 3 / 4 + 1, 0); - if (FAILURE == php_random_bytes_throw(ZSTR_VAL(buffer), ZSTR_LEN(buffer))) { - zend_value_error("Unable to generate salt"); - zend_string_efree(buffer); - return NULL; + for (size_t i = 0; i < len; i++) { + char c = in[i]; + /* phcb64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + * bcrypt: ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + * This is rather cryptic, but simpler than having tables. */ + if (c == 'A' || c == 'B') { + out[i] = c - 19; + } else if ((c >= 'C' && c <= 'Z') || (c >= 'c' && c <= 'z') || (c >= '2' && c <= '9')) { + out[i] = c - 2; + } else if (c == 'a' || c == 'b') { + out[i] = c - 8; + } else if (c == '0' || c == '1') { + out[i] = c + 73; + } else if (c == '+') { + out[i] = '8'; + } else if (c == '/') { + out[i] = '9'; + } else { + zend_value_error("Invalid base64 character encountered: %c", c); + return false; + } } - ret = zend_string_alloc(length, 0); - if (php_password_salt_to64(ZSTR_VAL(buffer), ZSTR_LEN(buffer), length, ZSTR_VAL(ret)) == FAILURE) { - zend_value_error("Generated salt too short"); - zend_string_efree(buffer); - zend_string_efree(ret); - return NULL; + /* The size boundaries work out in such a way that the hash is fully packed, but + * the salt is clamped by bcrypt and needs post-processing. */ + if (len == 22) { + char c = out[21]; + if (c == '/' || (c >= 'A' && c <= 'N')) { + out[21] = '.'; + } else if (c >= 'P' && c <= 'd') { + out[21] = 'O'; + } else if (c >= 'f' && c <= 't') { + out[21] = 'e'; + } else if (c >= 'v' || ZEND_IS_DIGIT(c)) { + out[21] = 'u'; + } } - zend_string_efree(buffer); - ZSTR_VAL(ret)[length] = 0; - return ret; + + out[len] = 0; + return true; } /* }}} */ -static zend_string* php_password_get_salt(zval *unused_, size_t required_salt_len, HashTable *options) { - if (options && zend_hash_str_exists(options, "salt", sizeof("salt") - 1)) { - php_error_docref(NULL, E_WARNING, "The \"salt\" option has been ignored, since providing a custom salt is no longer supported"); +static bool php_password_bcrypt_get_cost(zend_array *options, zend_long *cost) { + zval *zcost; + if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { + *cost = zval_get_long(zcost); } - return php_password_make_salt(required_salt_len); + return *cost >= 4 && *cost <= 31; } -/* bcrypt implementation */ - -static bool php_password_bcrypt_get_cost(const zend_string *hash, zend_long *cost) { +static bool php_password_bcrypt_parse(const zend_string *hash, zend_long *cost) { + unsigned int c = 0; + int r = 0; const char *h = ZSTR_VAL(hash); - if ((ZSTR_LEN(hash) != 60) || - (h[0] != '$') || (h[1] != '2') || (h[2] != 'y') || (h[3] != '$') || - !ZEND_IS_DIGIT(h[4]) || !ZEND_IS_DIGIT(h[5]) || (h[6] != '$')) { - return false; + if (ZSTR_LEN(hash) == 60 && ZEND_IS_DIGIT(h[4]) && ZEND_IS_DIGIT(h[5]) && + 1 == sscanf(ZSTR_VAL(hash), "$2y$%2u$%*21[./A-Za-z0-9]%*1[.Oeu]%*31[./A-Za-z0-9]%n", &c, &r) && + r == 60 && c >= 4 && c <= 31 + ) { + if (cost != NULL) { + *cost = (zend_long) c; + } + return true; } - *cost = (h[4] - '0') * 10 + (h[5] - '0'); - - return *cost >= 4 && *cost <= 31; + return false; } static bool php_password_bcrypt_valid(const zend_string *hash) { - zend_long cost; - - return php_password_bcrypt_get_cost(hash, &cost); + return php_password_bcrypt_parse(hash, NULL); } static int php_password_bcrypt_get_info(zval *return_value, const zend_string *hash) { zend_long cost; - if (!php_password_bcrypt_get_cost(hash, &cost)) { + if (!php_password_bcrypt_parse(hash, &cost)) { /* Should never get called this way. */ return FAILURE; } @@ -144,18 +198,16 @@ static int php_password_bcrypt_get_info(zval *return_value, const zend_string *h } static bool php_password_bcrypt_needs_rehash(const zend_string *hash, zend_array *options) { - zval *znew_cost; zend_long old_cost; zend_long new_cost = PHP_PASSWORD_BCRYPT_COST; - if (!php_password_bcrypt_get_cost(hash, &old_cost)) { + if (!php_password_bcrypt_parse(hash, &old_cost)) { /* Should never get called this way. */ - return true; - } - if (options && (znew_cost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { - new_cost = zval_get_long(znew_cost); + return 1; } + php_password_bcrypt_get_cost(options, &new_cost); + return old_cost != new_cost; } @@ -182,52 +234,64 @@ static bool php_password_bcrypt_verify(const zend_string *password, const zend_s return status == 0; } -static zend_string* php_password_bcrypt_hash(const zend_string *password, zend_array *options) { - char hash_format[10]; - size_t hash_format_len; - zend_string *result, *hash, *salt; - zval *zcost; - zend_long cost = PHP_PASSWORD_BCRYPT_COST; +/* bcrypt-wrap needs the salt during prehash, so yes the parameter here is necessary */ +static zend_string* php_password_bcrypt_hash_internal(const zend_string *password, const unsigned char (*salt)[PHP_BCRYPT_SALT_SIZE], const uint8_t cost) /* {{{ */ +{ + char hash_prefix[30]; + zend_string *hash, *salt64; - if (memchr(ZSTR_VAL(password), '\0', ZSTR_LEN(password))) { - zend_value_error("Bcrypt password must not contain null character"); + /* It's already validated */ + ZEND_ASSERT(cost >= 4 && cost <= 31); + + salt64 = php_base64_encode_ex((const unsigned char*) salt, PHP_BCRYPT_SALT_SIZE, PHP_BASE64_NO_PADDING); + ZEND_ASSERT(salt64 != NULL && ZSTR_LEN(salt64) == 22); + + if (!php_password_bcrypt_from_b64(ZSTR_VAL(salt64), ZSTR_VAL(salt64), 22)) { + zend_string_free(salt64); return NULL; } - if (options && (zcost = zend_hash_str_find(options, "cost", sizeof("cost")-1)) != NULL) { - cost = zval_get_long(zcost); - } + snprintf(hash_prefix, sizeof(hash_prefix), "$2y$%02u$%.22s", cost, ZSTR_VAL(salt64)); + ZEND_ASSERT(hash_prefix[28] == ZSTR_VAL(salt64)[21] && hash_prefix[29] == 0); + zend_string_free(salt64); - if (cost < 4 || cost > 31) { - zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); + /* This cast is safe, since both values are defined here in code and cannot overflow */ + hash = php_crypt(ZSTR_VAL(password), (int)ZSTR_LEN(password), hash_prefix, (int)sizeof(hash_prefix)-1, 1); + if (!hash) { return NULL; } - hash_format_len = snprintf(hash_format, sizeof(hash_format), "$2y$%02" ZEND_LONG_FMT_SPEC "$", cost); - if (!(salt = php_password_get_salt(NULL, Z_UL(22), options))) { + if (ZSTR_LEN(hash) != 60) { + zend_string_free(hash); return NULL; } - ZSTR_VAL(salt)[ZSTR_LEN(salt)] = 0; - hash = zend_string_concat2(hash_format, hash_format_len, ZSTR_VAL(salt), ZSTR_LEN(salt)); + return hash; +} +/* }}} */ - zend_string_release_ex(salt, 0); +static zend_string* php_password_bcrypt_hash(const zend_string *password, zend_array *options) /* {{{ */ +{ + unsigned char salt[PHP_BCRYPT_SALT_SIZE]; + zend_long cost = PHP_PASSWORD_BCRYPT_COST; - /* This cast is safe, since both values are defined here in code and cannot overflow */ - result = php_crypt(ZSTR_VAL(password), (int)ZSTR_LEN(password), ZSTR_VAL(hash), (int)ZSTR_LEN(hash), 1); - zend_string_release_ex(hash, 0); + if (memchr(ZSTR_VAL(password), '\0', ZSTR_LEN(password))) { + zend_value_error("Bcrypt password must not contain null character"); + return NULL; + } - if (!result) { + if (!php_password_bcrypt_get_cost(options, &cost)) { + zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); return NULL; } - if (ZSTR_LEN(result) < 13) { - zend_string_free(result); + if (!php_password_make_salt(salt, PHP_BCRYPT_SALT_SIZE)) { return NULL; } - return result; + return php_password_bcrypt_hash_internal(password, &salt, cost); } +/* }}} */ const php_password_algo php_password_algo_bcrypt = { "bcrypt", @@ -238,7 +302,6 @@ const php_password_algo php_password_algo_bcrypt = { php_password_bcrypt_valid, }; - #ifdef HAVE_ARGON2LIB /* argon2i/argon2id shared implementation */ @@ -308,7 +371,9 @@ static bool php_password_argon2_needs_rehash(const zend_string *hash, zend_array static zend_string *php_password_argon2_hash(const zend_string *password, zend_array *options, argon2_type type) { zval *option_buffer; - zend_string *salt, *out, *encoded; + zend_string *out, *encoded; + size_t salt_len = 16; + unsigned char salt[salt_len]; size_t time_cost = PHP_PASSWORD_ARGON2_TIME_COST; size_t memory_cost = PHP_PASSWORD_ARGON2_MEMORY_COST; size_t threads = PHP_PASSWORD_ARGON2_THREADS; @@ -342,7 +407,7 @@ static zend_string *php_password_argon2_hash(const zend_string *password, zend_a return NULL; } - if (!(salt = php_password_get_salt(NULL, Z_UL(16), options))) { + if (!php_password_make_salt(salt, salt_len)) { return NULL; } @@ -351,7 +416,7 @@ static zend_string *php_password_argon2_hash(const zend_string *password, zend_a time_cost, memory_cost, threads, - (uint32_t)ZSTR_LEN(salt), + (uint32_t)salt_len, ZSTR_LEN(out), type ); @@ -363,8 +428,8 @@ static zend_string *php_password_argon2_hash(const zend_string *password, zend_a threads, ZSTR_VAL(password), ZSTR_LEN(password), - ZSTR_VAL(salt), - ZSTR_LEN(salt), + salt, + salt_len, ZSTR_VAL(out), ZSTR_LEN(out), ZSTR_VAL(encoded), @@ -374,7 +439,6 @@ static zend_string *php_password_argon2_hash(const zend_string *password, zend_a ); zend_string_release_ex(out, 0); - zend_string_release_ex(salt, 0); if (status != ARGON2_OK) { zend_string_efree(encoded); @@ -444,6 +508,10 @@ PHP_MINIT_FUNCTION(password) /* {{{ */ } #endif + if (FAILURE == php_password_algo_register("bcrypt-wrap", &php_password_algo_bcrypt_wrap)) { + return FAILURE; + } + return SUCCESS; } /* }}} */ @@ -510,6 +578,7 @@ static const php_password_algo* php_password_algo_find_zval(zend_string *arg_str return ret; } #endif + case 4: return &php_password_algo_bcrypt_wrap; } return NULL; @@ -655,6 +724,10 @@ PHP_FUNCTION(password_hash) RETURN_THROWS(); } + if (options && zend_hash_str_exists(options, "salt", sizeof("salt") - 1)) { + php_error_docref(NULL, E_WARNING, "The \"salt\" option has been ignored, since providing a custom salt is no longer supported"); + } + digest = algo->hash(password, options); if (!digest) { if (!EG(exception)) { @@ -679,3 +752,325 @@ PHP_FUNCTION(password_algos) { } ZEND_HASH_FOREACH_END(); } /* }}} */ + +/* bcrypt-wrap */ + +#define PHP_BCRYPT_WRAP_VERSION 0 + +typedef struct bcrypt_wrap_ops { + uint8_t version; + uint8_t cost; + char prehash[10]; +} bcrypt_wrap_ops; + +static bool php_password_bcrypt_wrap_get_options(zend_array *options, bcrypt_wrap_ops *ops) /* {{{ */ +{ + zval *zv; + zend_long cost = PHP_PASSWORD_BCRYPT_COST; + + if (!php_password_bcrypt_get_cost(options, &cost)) { + zend_value_error("Invalid bcrypt cost parameter specified: " ZEND_LONG_FMT, cost); + return false; + } + + *ops = (bcrypt_wrap_ops) { + .version = PHP_BCRYPT_WRAP_VERSION, + .cost = cost, + .prehash = {0} + }; + + if (options && (zv = zend_hash_str_find(options, "prehash", sizeof("prehash")-1)) != NULL) { + zend_string *prehash = zval_get_string(zv); + + if (zend_string_equals_literal(prehash, "cshake256")) { + snprintf(ops->prehash, sizeof(ops->prehash), "cshake256"); + } else if (zend_string_equals_literal(prehash, "sha512")) { + snprintf(ops->prehash, sizeof(ops->prehash), "sha512"); + } else if (!zend_string_equals_literal(prehash, "cshake256")) { + zend_value_error("Invalid bcrypt-wrap prehash parameter specified: %s", ZSTR_VAL(prehash)); + zend_string_release(prehash); + return false; + } + + zend_string_release(prehash); + } else { + snprintf(ops->prehash, sizeof(ops->prehash), "cshake256"); + } + + return true; +} +/* }}} */ + +static bool php_password_bcrypt_wrap_parse(const zend_string *hash, bcrypt_wrap_ops *ops) /* {{{ */ +{ + unsigned int v, cost; + char prehash[10]; + int r = 0; + const char *h = ZSTR_VAL(hash); + + if (ZSTR_LEN(hash) < 93 || ZSTR_LEN(hash) > 97 || !ZEND_IS_DIGIT(h[15]) || !ZEND_IS_DIGIT(h[22]) || h[22] == '0') { + return false; + } + + if (3 != sscanf(h, "$bcrypt-wrap$v=%1u$cost=%2u,prehash=%9[a-z0-9]$%*21[A-Za-z0-9+/]%*1[AQgw]$%*31[A-Za-z0-9+/]%n", + &v, &cost, prehash, &r) || (size_t)r != ZSTR_LEN(hash) || cost < 4 || cost > 31 + ) { + return false; + } + + if (v > PHP_BCRYPT_WRAP_VERSION) { + return false; + } + + if (strcmp(prehash, "cshake256") != 0 && strcmp(prehash, "sha512") != 0) { + return false; + } + + if (ops != NULL) { + *ops = (bcrypt_wrap_ops) { + .version = v, + .cost = cost, + .prehash = {0} + }; + snprintf(ops->prehash, sizeof(ops->prehash), "%.9s", prehash); + } + + return true; +} +/* }}} */ + +static zend_string* php_password_bcrypt_wrap_prehash(const zend_string *password, const bcrypt_wrap_ops *ops, const unsigned char *salt, size_t salt_len) /* {{{ */ +{ + /* 72 bytes after base64 encoding */ + const size_t raw_len = 54; + unsigned char raw[raw_len]; + /* Dynamic sizes not needed anytime soon, if at all, but still */ + static const char prefix[] = "bcrypt-wrap:%u:"; + const size_t prefix_len = snprintf(NULL, 0, prefix, ops->version); + unsigned char psalt[prefix_len + salt_len]; + + /* Prehash salt is technically not bound by Bcrypt's limit. + * Highly unlikely, but a future version could increase its size. */ + ZEND_ASSERT(salt_len >= PHP_BCRYPT_SALT_SIZE); + snprintf((char *) psalt, sizeof(psalt), prefix, ops->version); + memcpy(psalt + prefix_len, salt, salt_len); + +#if ZEND_DEBUG + /* Official test vectors from NIST, here for PoC purposes, as this is our first + * use of cSHAKE256. Can probably be dropped from final patch after review. + * https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/cSHAKE_samples.pdf + * (tail of page 8 until end of document) */ + const unsigned char cstr[15] = "Email Signature"; + const unsigned char data1[4] = { 0x00, 0x01, 0x02, 0x03 }; + const unsigned char out1[64] = { + 0xD0, 0x08, 0x82, 0x8E, 0x2B, 0x80, 0xAC, 0x9D, 0x22, 0x18, 0xFF, 0xEE, 0x1D, 0x07, 0x0C, 0x48, + 0xB8, 0xE4, 0xC8, 0x7B, 0xFF, 0x32, 0xC9, 0x69, 0x9D, 0x5B, 0x68, 0x96, 0xEE, 0xE0, 0xED, 0xD1, + 0x64, 0x02, 0x0E, 0x2B, 0xE0, 0x56, 0x08, 0x58, 0xD9, 0xC0, 0x0C, 0x03, 0x7E, 0x34, 0xA9, 0x69, + 0x37, 0xC5, 0x61, 0xA7, 0x4C, 0x41, 0x2B, 0xB4, 0xC7, 0x46, 0x46, 0x95, 0x27, 0x28, 0x1C, 0x8C + }; + unsigned char data2[200]; + for (size_t i = 0; i < sizeof(data2); i++) { + data2[i] = i; + } + const unsigned char out2[64] = { + 0x07, 0xDC, 0x27, 0xB1, 0x1E, 0x51, 0xFB, 0xAC, 0x75, 0xBC, 0x7B, 0x3C, 0x1D, 0x98, 0x3E, 0x8B, + 0x4B, 0x85, 0xFB, 0x1D, 0xEF, 0xAF, 0x21, 0x89, 0x12, 0xAC, 0x86, 0x43, 0x02, 0x73, 0x09, 0x17, + 0x27, 0xF4, 0x2B, 0x17, 0xED, 0x1D, 0xF6, 0x3E, 0x8E, 0xC1, 0x18, 0xF0, 0x4B, 0x23, 0x63, 0x3C, + 0x1D, 0xFB, 0x15, 0x74, 0xC8, 0xFB, 0x55, 0xCB, 0x45, 0xDA, 0x8E, 0x25, 0xAF, 0xB0, 0x92, 0xBB + }; + + unsigned char buffer[64]; + php_hash_cshake256(data1, sizeof(data1), cstr, sizeof(cstr), buffer, sizeof(out1)); + ZEND_ASSERT(memcmp(out1, buffer, 64) == 0); + php_hash_cshake256(data2, sizeof(data2), cstr, sizeof(cstr), buffer, sizeof(out2)); + ZEND_ASSERT(memcmp(out2, buffer, 64) == 0); + /* One more for our intended size */ + memset(buffer, 0, sizeof(buffer)); + php_hash_cshake256(data2, sizeof(data2), cstr, sizeof(cstr), buffer, raw_len); + ZEND_ASSERT(memcmp(out2, buffer, raw_len) == 0 && buffer[raw_len] == 0); +#endif + + if (strcmp(ops->prehash, "cshake256") == 0) { + /* Salt goes into the customization string as defined by SP 800-185 */ + php_hash_cshake256( + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), + psalt, sizeof(psalt), + raw, raw_len + ); + } else if (strcmp(ops->prehash, "sha512") == 0) { + php_hash_hmac( + "sha512", sizeof("sha512")-1, + (const unsigned char *) ZSTR_VAL(password), ZSTR_LEN(password), + psalt, sizeof(psalt), + raw, raw_len + ); + } else { + /* Failsafe, but should not be possible as there are prior guards. + * ops->prehash is not written to before correct value is determined. */ + zend_value_error("Invalid bcrypt-wrap prehash parameter specified: %s", ops->prehash); + return NULL; + } + + ZEND_ASSERT(raw != NULL); + return php_base64_encode_ex((const unsigned char *) raw, raw_len, PHP_BASE64_NO_PADDING); +} +/* }}} */ + +static zend_string* php_password_bcrypt_wrap_hash(const zend_string *password, zend_array *options) /* {{{ */ +{ + bcrypt_wrap_ops ops; + unsigned char salt[PHP_BCRYPT_SALT_SIZE]; + char hash64[32]; + zend_string *salt64, *prehash, *hash; + + /* 1KiB is a thought-terminating limit that nobody can ever complain about. + * It's not to impose a limit on passwords, but to guard against extreme misuse + * and shut down any talk of choking the prehash with megabytes of data. */ + if (ZSTR_LEN(password) > 1024) { + zend_value_error("Bcrypt-wrap password must not be larger than 1KiB"); + return NULL; + } + + if (!php_password_bcrypt_wrap_get_options(options, &ops)) { + return NULL; + } + + if (!php_password_make_salt(salt, PHP_BCRYPT_SALT_SIZE)) { + return NULL; + } + + prehash = php_password_bcrypt_wrap_prehash(password, &ops, salt, PHP_BCRYPT_SALT_SIZE); + ZEND_ASSERT(prehash != NULL); + if (!(hash = php_password_bcrypt_hash_internal(prehash, &salt, ops.cost))) { + zend_string_free(prehash); + return NULL; + } + ZEND_SECURE_ZERO(ZSTR_VAL(prehash), ZSTR_LEN(prehash)); + zend_string_free(prehash); + + /* Can't fail unless bcrypt code is broken */ + if (sscanf(ZSTR_VAL(hash), "$2y$%*2[0-9]$%*22[a-zA-Z0-9/.]%31[a-zA-Z0-9/.]", hash64) != 1) { + zend_string_free(hash); + return NULL; + } + zend_string_free(hash); + php_password_b64_from_bcrypt(hash64, hash64, 31); + + salt64 = php_base64_encode_ex((const unsigned char*) salt, PHP_BCRYPT_SALT_SIZE, PHP_BASE64_NO_PADDING); + + hash = strpprintf(0, "$bcrypt-wrap$v=%u$cost=%u,prehash=%s$%s$%s", ops.version, ops.cost, ops.prehash, ZSTR_VAL(salt64), hash64); + zend_string_free(salt64); + ZEND_ASSERT(ZSTR_LEN(hash) == 86 + strlen(ops.prehash) + (ops.cost < 10 ? 1 : 2)); + + return hash; +} +/* }}} */ + +static bool php_password_bcrypt_wrap_valid(const zend_string *hash) /* {{{ */ +{ + return php_password_bcrypt_wrap_parse(hash, NULL); +} +/* }}} */ + +static bool php_password_bcrypt_wrap_verify(const zend_string *password, const zend_string *hash) /* {{{ */ +{ + int r = 0; + char salt64[23], hash64[32]; + bcrypt_wrap_ops ops; + zend_string *salt, *prehash; + + if (ZSTR_LEN(password) > 1024 || !php_password_bcrypt_wrap_parse(hash, &ops)) { + return false; + } + + if (sscanf(ZSTR_VAL(hash) + (ZSTR_LEN(hash) - 54), "%22[A-Za-z0-9+/]$%31[A-Za-z0-9+/]%n", salt64, hash64, &r) != 2 || r != 54) { + return false; + } + + if (!(salt = php_base64_decode((const unsigned char *) salt64, 22))) { + return false; + } + + if (ZSTR_LEN(salt) != PHP_BCRYPT_SALT_SIZE) { + zend_string_free(salt); + return false; + } + + prehash = php_password_bcrypt_wrap_prehash(password, &ops, (const unsigned char *) ZSTR_VAL(salt), PHP_BCRYPT_SALT_SIZE); + zend_string_free(salt); + + if (!prehash) { + return false; + } + + if (!php_password_bcrypt_from_b64(salt64, salt64, 22)) { + return false; + } + + if (!php_password_bcrypt_from_b64(hash64, hash64, 31)) { + return false; + } + + zend_string *stringed = strpprintf(0, "$2y$%02u$%.22s%.31s", ops.cost, salt64, hash64); + zend_string *computed = php_crypt(ZSTR_VAL(prehash), (int) ZSTR_LEN(prehash), ZSTR_VAL(stringed), (int) ZSTR_LEN(stringed), 1); + ZEND_SECURE_ZERO(ZSTR_VAL(prehash), ZSTR_LEN(prehash)); + zend_string_free(prehash); + + if (!computed) { + zend_string_free(stringed); + return false; + } + + if (ZSTR_LEN(computed) != 60) { + zend_string_free(stringed); + zend_string_free(computed); + return false; + } + + /* Constant-time check, don't optimize for performance */ + r = php_safe_bcmp(stringed, computed); + zend_string_free(stringed); + zend_string_free(computed); + + return r == 0; +} +/* }}} */ + +static bool php_password_bcrypt_wrap_needs_rehash(const zend_string *hash, zend_array *options) /* {{{ */ +{ + bcrypt_wrap_ops old; + bcrypt_wrap_ops new; + + if (!php_password_bcrypt_wrap_parse(hash, &old)) { + return true; + } + + if (!php_password_bcrypt_wrap_get_options(options, &new)) { + return false; + } + + return old.version < new.version || old.cost != new.cost || strcmp(old.prehash, new.prehash) != 0; +} +/* }}} */ + +static int php_password_bcrypt_wrap_get_info(zval *return_value, const zend_string *hash) /* {{{ */ +{ + bcrypt_wrap_ops ops; + + if (!php_password_bcrypt_wrap_parse(hash, &ops)) { + return FAILURE; + } + add_assoc_long(return_value, "cost", (zend_long) ops.cost); + add_assoc_stringl(return_value, "prehash", ops.prehash, strlen(ops.prehash)); + return SUCCESS; +} +/* }}} */ + +const php_password_algo php_password_algo_bcrypt_wrap = { + "bcrypt-wrap", + php_password_bcrypt_wrap_hash, + php_password_bcrypt_wrap_verify, + php_password_bcrypt_wrap_needs_rehash, + php_password_bcrypt_wrap_get_info, + php_password_bcrypt_wrap_valid +}; diff --git a/ext/standard/password.stub.php b/ext/standard/password.stub.php index 98c78032c9f8..a8b69a03e59f 100644 --- a/ext/standard/password.stub.php +++ b/ext/standard/password.stub.php @@ -3,6 +3,7 @@ /** @generate-class-entries */ const PASSWORD_BCRYPT = "2y"; +const PASSWORD_BCRYPT_WRAP = "bcrypt-wrap"; /** * @var int * @cvalue PHP_PASSWORD_BCRYPT_COST diff --git a/ext/standard/password_arginfo.h b/ext/standard/password_arginfo.h index bbfe8d16aa37..88c6091a4f49 100644 --- a/ext/standard/password_arginfo.h +++ b/ext/standard/password_arginfo.h @@ -1,11 +1,12 @@ /* This is a generated file, edit password.stub.php instead. - * Stub hash: 589ff98a8fd6883f11558d4984b89a0467e756e0 */ + * Stub hash: 93162f1cd7a6680d74449dcf8a498e397d4d02ce */ #include "zend_constants.h" static void register_password_symbols(int module_number) { REGISTER_STRING_CONSTANT("PASSWORD_BCRYPT", "2y", CONST_PERSISTENT); + REGISTER_STRING_CONSTANT("PASSWORD_BCRYPT_WRAP", "bcrypt-wrap", CONST_PERSISTENT); REGISTER_LONG_CONSTANT("PASSWORD_BCRYPT_DEFAULT_COST", PHP_PASSWORD_BCRYPT_COST, CONST_PERSISTENT); #if defined(HAVE_ARGON2LIB) REGISTER_STRING_CONSTANT("PASSWORD_ARGON2I", "argon2i", CONST_PERSISTENT); diff --git a/ext/standard/php_password.h b/ext/standard/php_password.h index 6442b782c256..9e285d94f9f4 100644 --- a/ext/standard/php_password.h +++ b/ext/standard/php_password.h @@ -48,6 +48,7 @@ extern const php_password_algo php_password_algo_bcrypt; extern const php_password_algo php_password_algo_argon2i; extern const php_password_algo php_password_algo_argon2id; #endif +extern const php_password_algo php_password_algo_bcrypt_wrap; PHPAPI int php_password_algo_register(const char*, const php_password_algo*); PHPAPI void php_password_algo_unregister(const char*); diff --git a/ext/standard/tests/password/password_bcrypt_wrap_errors.phpt b/ext/standard/tests/password/password_bcrypt_wrap_errors.phpt new file mode 100644 index 000000000000..a8352aaf1410 --- /dev/null +++ b/ext/standard/tests/password/password_bcrypt_wrap_errors.phpt @@ -0,0 +1,29 @@ +--TEST-- +Test error operation of password_hash() with bcrypt-wrap +--FILE-- + 3], + ["cost" => 32], + ["cost" => 4, "prehash" => "sha256"] +]; + +foreach ($params as $p) { + try { + password_hash("foo", PASSWORD_BCRYPT_WRAP, $p); + } catch (Throwable $exception) { + echo $exception::class, ': ', $exception->getMessage(), "\n"; + } +} + +try { + password_hash(str_repeat("f", 1025), PASSWORD_BCRYPT_WRAP, ["cost"=>4]); +} catch (Throwable $exception) { + echo $exception::class, ': ', $exception->getMessage(), "\n"; +} +?> +--EXPECT-- +ValueError: Invalid bcrypt cost parameter specified: 3 +ValueError: Invalid bcrypt cost parameter specified: 32 +ValueError: Invalid bcrypt-wrap prehash parameter specified: sha256 +ValueError: Bcrypt-wrap password must not be larger than 1KiB diff --git a/ext/standard/tests/password/password_get_info.phpt b/ext/standard/tests/password/password_get_info.phpt index 29dd78fbc1f0..7c2651a01a06 100644 --- a/ext/standard/tests/password/password_get_info.phpt +++ b/ext/standard/tests/password/password_get_info.phpt @@ -9,6 +9,7 @@ var_dump(password_get_info('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vj var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y')); // Test Bcrypt Invalid Length var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100')); +var_dump(password_get_info('$2y$11$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj1000y')); // Test Non-Bcrypt var_dump(password_get_info('$1$rasmusle$rISCgZzpwk3UhDidwXvin0')); @@ -26,6 +27,13 @@ $invalidHashes = [ 'below range' => '$2y$03$' . $suffix, 'above range' => '$2y$32$' . $suffix, 'missing separator' => '$2y$' . str_repeat('9', 56), + 'malformed salt segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1NEj0NmcAWSLR.oP7XOR9HD/vjUuOj100y', + 'malformed hash segment' => '$2y$04$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', + 'malicious misalignment' => '$2y$4$MTIzNDU2Nzg5MDEyMzQ1Neej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', + 'minus sign' => '$2y$-4$' . $suffix, + 'plus sign' => '$2y$+4$' . $suffix, + 'newline' => '$2y$'."\n".'4$' . $suffix, + 'space' => '$2y$ 4$' . $suffix, ]; foreach ($invalidHashes as $description => $hash) { $info = password_get_info($hash); @@ -75,6 +83,15 @@ array(3) { array(0) { } } +array(3) { + ["algo"]=> + NULL + ["algoName"]=> + string(7) "unknown" + ["options"]=> + array(0) { + } +} valid 4: bcrypt, cost 4 valid 31: bcrypt, cost 31 non-digit: unknown, options 0 @@ -82,4 +99,11 @@ malformed separator: unknown, options 0 below range: unknown, options 0 above range: unknown, options 0 missing separator: unknown, options 0 +malformed salt segment: unknown, options 0 +malformed hash segment: unknown, options 0 +malicious misalignment: unknown, options 0 +minus sign: unknown, options 0 +plus sign: unknown, options 0 +newline: unknown, options 0 +space: unknown, options 0 OK! diff --git a/ext/standard/tests/password/password_get_info_bcrypt_wrap.phpt b/ext/standard/tests/password/password_get_info_bcrypt_wrap.phpt new file mode 100644 index 000000000000..b99639b58eaf --- /dev/null +++ b/ext/standard/tests/password/password_get_info_bcrypt_wrap.phpt @@ -0,0 +1,91 @@ +--TEST-- +Test normal operation of password_get_info() with bcrypt-wrap +--FILE-- + "0", "cost" => "12", "prehash" => "cshake256", "salt" => "61bdXBR/Y1z+BJfnwDRpZQ", "hash" => "JY3BZsZi+7FTYa00u/s5CVSclj6hA6c"]; + +$missing = [ + "v" => sprintf(str_replace("v=%s$", "", $template), $goodvals["cost"], $goodvals["prehash"], $goodvals["salt"], $goodvals["hash"]), + "cost" => sprintf(str_replace("cost=%s,", "", $template), $goodvals["v"], $goodvals["prehash"], $goodvals["salt"], $goodvals["hash"]), + "prehash" => sprintf(str_replace(",prehash=%s", "", $template), $goodvals["v"], $goodvals["cost"], $goodvals["salt"], $goodvals["hash"]), + "salt" => sprintf(str_replace("$%s$", "$", $template), $goodvals["v"], $goodvals["cost"], $goodvals["prehash"], $goodvals["hash"]), + "hash" => sprintf(str_replace("$%s$%s", "$%s", $template), $goodvals["v"], $goodvals["cost"], $goodvals["prehash"], $goodvals["salt"]), +]; + +foreach ($missing as $name => $val) { + $info = password_get_info($val); + printf("missing %s :: %s\n", $name, $info["algoName"]); +} + +foreach (["", " ", "0", "+", "-1", "10", PHP_INT_MAX] as $v) { + $info = password_get_info(sprintf($template, $v, $goodvals["cost"], $goodvals["prehash"], $goodvals["salt"], $goodvals["hash"])); + printf("v=%s :: %s\n", $v, $info["algoName"]); +} + +foreach (["", " ", "-10", "3", "-4", "+4", " 4", "4", "31", "32"] as $cost) { + $info = password_get_info(sprintf($template, $goodvals["v"], $cost, $goodvals["prehash"], $goodvals["salt"], $goodvals["hash"])); + printf("cost=%s :: %s\n", $cost, $info["algoName"]); +} + +foreach (["", " ", "cshake256", "CSHAKE256", "sha512", "SHA512", "sha-512", "sha256"] as $prehash) { + $info = password_get_info(sprintf($template, $goodvals["v"], $goodvals["cost"], $prehash, $goodvals["salt"], $goodvals["hash"])); + printf("prehash=%s :: %s\n", $prehash, $info["algoName"]); +} + +$payloads = [ + "short salt" => ["salt" => substr($goodvals["salt"], 0, -1)], + "long salt" => ["salt" => $goodvals["salt"]."s"], + "short hash" => ["hash" => substr($goodvals["hash"], 0, 1)], + "long hash" => ["hash" => $goodvals["hash"]."h"], + "misaligned" => ["salt" => $goodvals["salt"]."s", "hash" => substr($goodvals["hash"], 0, -1)], + "bad salt" => ["salt" => substr($goodvals["salt"], 0, -1)."."], + "bad hash" => ["hash" => substr($goodvals["hash"], 0, -1)."."], +]; + +foreach ($payloads as $name => $val) { + $info = password_get_info(sprintf($template, $goodvals["v"], $goodvals["cost"], $goodvals["prehash"], $val["salt"] ?? $goodvals["salt"], $val["hash"] ?? $goodvals["hash"])); + printf("%s :: %s\n", $name, $info["algoName"]); +} + +echo "OK!"; +?> +--EXPECT-- +missing v :: unknown +missing cost :: unknown +missing prehash :: unknown +missing salt :: unknown +missing hash :: unknown +v= :: unknown +v= :: unknown +v=0 :: bcrypt-wrap +v=+ :: unknown +v=-1 :: unknown +v=10 :: unknown +v=9223372036854775807 :: unknown +cost= :: unknown +cost= :: unknown +cost=-10 :: unknown +cost=3 :: unknown +cost=-4 :: unknown +cost=+4 :: unknown +cost= 4 :: unknown +cost=4 :: bcrypt-wrap +cost=31 :: bcrypt-wrap +cost=32 :: unknown +prehash= :: unknown +prehash= :: unknown +prehash=cshake256 :: bcrypt-wrap +prehash=CSHAKE256 :: unknown +prehash=sha512 :: bcrypt-wrap +prehash=SHA512 :: unknown +prehash=sha-512 :: unknown +prehash=sha256 :: unknown +short salt :: unknown +long salt :: unknown +short hash :: unknown +long hash :: unknown +misaligned :: unknown +bad salt :: unknown +bad hash :: unknown +OK! diff --git a/ext/standard/tests/password/password_hash_bcrypt_wrap.phpt b/ext/standard/tests/password/password_hash_bcrypt_wrap.phpt new file mode 100644 index 000000000000..49364c7bf0c6 --- /dev/null +++ b/ext/standard/tests/password/password_hash_bcrypt_wrap.phpt @@ -0,0 +1,44 @@ +--TEST-- +Test normal operation of password_hash() with bcrypt_wrap +--SKIPIF-- + +--FILE-- + 4, "prehash" => "cshake256"])); +var_dump($hash = password_hash("foo", 4, ["cost" => 4, "prehash" => "sha512"])); +var_dump(password_get_info($hash)['algo']); + +$nullpass = "null\x00byte"; +$longpass = str_repeat("a", 72); + +foreach (["cshake256", "sha512"] as $prehash) { + $hash = password_hash($nullpass, PASSWORD_BCRYPT_WRAP, ["cost" => 4, "prehash" => $prehash]); + var_dump(password_verify($nullpass, $hash)); + var_dump(password_verify($nullpass."suffix", $hash)); + $hash = password_hash($longpass."suffix", PASSWORD_BCRYPT_WRAP, ["cost" => 4, "prehash" => $prehash]); + var_dump(password_verify($longpass."suffix", $hash)); + var_dump(password_verify($longpass."suffix2", $hash)); + var_dump(password_get_info($hash)['options']['prehash']); +} + +echo "OK!"; +?> +--EXPECTF-- +string(97) "$bcrypt-wrap$v=0$cost=12,prehash=cshake256$%s$%s" +string(96) "$bcrypt-wrap$v=0$cost=4,prehash=cshake256$%s$%s" +string(93) "$bcrypt-wrap$v=0$cost=4,prehash=sha512$%s$%s" +string(11) "bcrypt-wrap" +bool(true) +bool(false) +bool(true) +bool(false) +string(9) "cshake256" +bool(true) +bool(false) +bool(true) +bool(false) +string(6) "sha512" +OK! diff --git a/ext/standard/tests/password/password_needs_rehash.phpt b/ext/standard/tests/password/password_needs_rehash.phpt index f8390996f234..111822bca041 100644 --- a/ext/standard/tests/password/password_needs_rehash.phpt +++ b/ext/standard/tests/password/password_needs_rehash.phpt @@ -9,6 +9,10 @@ var_dump(password_needs_rehash('', PASSWORD_BCRYPT)); var_dump(password_needs_rehash('', 1)); var_dump(password_needs_rehash('', '2y')); +// Plausible-looking, but malformed hashes +var_dump(password_needs_rehash('$2y$04$MTIzNDU2Nzg5MDEyMzQ1NEj0NmcAWSLR.oP7XOR9HD/vjUuOj100y', PASSWORD_BCRYPT)); +var_dump(password_needs_rehash('$2y$04$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR+oP7XOR9HD/vjUuOj100y', PASSWORD_BCRYPT)); + // Valid, as it's an unknown algorithm var_dump(password_needs_rehash('', PASSWORD_DEFAULT)); var_dump(password_needs_rehash('', 0)); @@ -42,6 +46,10 @@ $costCases = [ 'malformed separator' => ['$2y$10x' . $suffix, 10], 'below range' => ['$2y$03$' . $suffix, 3], 'above range' => ['$2y$32$' . $suffix, 32], + 'minus sign' => ['$2y$-4$' . $suffix, 4], + 'plus sign' => ['$2y$+4$' . $suffix, 4], + 'newline' => ['$2y$'."\n".'4$' . $suffix, 4], + 'space' => ['$2y$ 4$' . $suffix, 4], ]; foreach ($costCases as $description => $case) { echo $description, ': '; @@ -63,6 +71,8 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) +bool(true) bool(false) bool(false) bool(true) @@ -75,6 +85,10 @@ non-digit: bool(true) malformed separator: bool(true) below range: bool(true) above range: bool(true) +minus sign: bool(true) +plus sign: bool(true) +newline: bool(true) +space: bool(true) bool(true) bool(true) OK! diff --git a/ext/standard/tests/password/password_needs_rehash_bcrypt_wrap.phpt b/ext/standard/tests/password/password_needs_rehash_bcrypt_wrap.phpt new file mode 100644 index 000000000000..5cb001e4114d --- /dev/null +++ b/ext/standard/tests/password/password_needs_rehash_bcrypt_wrap.phpt @@ -0,0 +1,46 @@ +--TEST-- +Test normal operation of password_needs_rehash() with bcrypt-wrap +--FILE-- + 4, "prehash" => "cshake256"]), true); + echo "version {$v} :: {$result}\n"; +} + +// Descriptions refer to the hash being parsed +$tests = [ + "same cost" => ["cost" => [4, 4]], + "lower cost" => ["cost" => [4, 31]], + "higher cost" => ["cost" => [5, 4]], + "cshake256 to cshake256" => ["prehash" => ["cshake256", "cshake256"]], + "cshake256 to sha512" => ["prehash" => ["cshake256", "sha512"]], + "sha512 to sha512" => ["prehash" => ["sha512", "sha512"]], + "sha512 to cshake256" => ["prehash" => ["sha512", "cshake256"]], +]; + +foreach ($tests as $name => $options) { + $hash = sprintf($template, $current_version, $options["cost"][0] ?? PASSWORD_BCRYPT_DEFAULT_COST, $options["prehash"][0] ?? $default_prehash); + $result = var_export(password_needs_rehash($hash, PASSWORD_BCRYPT_WRAP, [ + "cost" => $options["cost"][1] ?? PASSWORD_BCRYPT_DEFAULT_COST, + "prehash" => $options["prehash"][1] ?? $default_prehash + ]), true); + echo "{$name} :: {$result}\n"; +} + +echo "OK!"; +?> +--EXPECT-- +version 0 :: false +same cost :: false +lower cost :: true +higher cost :: true +cshake256 to cshake256 :: false +cshake256 to sha512 :: true +sha512 to sha512 :: false +sha512 to cshake256 :: true +OK! diff --git a/ext/standard/tests/password/password_verify_bcrypt_wrap.phpt b/ext/standard/tests/password/password_verify_bcrypt_wrap.phpt new file mode 100644 index 000000000000..d4c6bd5ef9b2 --- /dev/null +++ b/ext/standard/tests/password/password_verify_bcrypt_wrap.phpt @@ -0,0 +1,56 @@ +--TEST-- +Test normal operation of password_verify() with bcrypt-wrap +--SKIPIF-- + +--FILE-- + "foo", + "hash" => '$bcrypt-wrap$v=0$cost=10,prehash=cshake256$bfJceq0aXoBAI28hkEyTDA$BhYZPQcj3KnqdpKaK/Xd3Vg09a1HC0A' + ], + [ + "pass" => "\00\x01\x02\x03", + "hash" => '$bcrypt-wrap$v=0$cost=4,prehash=cshake256$eT1y0ul8SrOZlfBE7JlpNQ$MK6JKABH7ToMyIvuXS0a4UhPzW5Av+c' + ], + [ + "pass" => str_repeat("f", 73), + "hash" => '$bcrypt-wrap$v=0$cost=5,prehash=sha512$ditAGtUdwL761dx7dFyIsQ$ELKJD3syWZZ6tA/OcB8Sv+UvYTw/shk' + ], +]; + +foreach ($tests as $t) { + var_dump(password_verify($t["pass"], $t["hash"])); + var_dump(password_verify($t["pass"]."\x00\x46", $t["hash"])); +} + +$limit_pass = str_repeat("f", 1024); +$limit_hash = '$bcrypt-wrap$v=0$cost=4,prehash=cshake256$G26CVD/4p2TIBlGkLmR3XQ$2MrQUpU2LI0k0HmIDKhVtmZUHyigmUU'; + +$computed_s = hrtime(true); +var_dump(password_verify($limit_pass, $limit_hash)); +$computed_e = hrtime(true); +$computed_t = $computed_e - $computed_s; + +$rejected_s = hrtime(true); +var_dump(password_verify($limit_pass."f", $limit_hash)); +$rejected_e = hrtime(true); +$rejected_t = $rejected_e - $rejected_s; + +// The actual ratio is many times over, since no heavy computation would've occured +var_dump(($computed_t / $rejected_t) > 10); + +echo "OK!"; +?> +--EXPECT-- +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +OK!