I have a script that creates a secure token for browser login remember-me like so:
$token = random_bytes(32);
This is then saved to a database for comparison later on:
$sql = " INSERT INTO auth_tokens (user, user_email, auth_type, selector, token, expires_at) VALUES (?, ?, 'remember_me', ?, ?, ?) ";
$stmt = mysqli_stmt_init($conn);
$hashedToken = password_hash($token, PASSWORD_DEFAULT);
// ... more param definitions
mysqli_stmt_bind_param($stmt, $s, $username, $email, $selector, $hashedToken, $date);
mysqli_stmt_execute($stmt);
The problem with this is that random_bytes()
sometimes generates a null character, which then causes password_hash()
to throw an error. I believe this fatal ValueError was added to Bcrypt recently.
PHP Fatal error: Uncaught ValueError: Bcrypt password must not contain null character in *** Stack trace:***: password_hash() {main} thrown in ***
What would be the correct way to prevent null chars generated by random_bytes()
? Does it make sense to simply str_replace('\0', '', $token)
?
As stated in the comments, this "Bcrypt password must not contain null character" restriction was introduced somewhat recently (April 9th, 2024).
What would be the correct way to prevent null chars generated by random_bytes()? Does it make sense to simply str_replace('\0', '', $token)?
In your sample str_replace
code, double quotes are required around "\0"
to match null bytes. Most escape sequences are not supported in single-quoted strings. Your sample code would also reduce the length of your token, which is probably unwanted.
Here's one way to generate a random non-null-byte sequence of a specified length:
<?php
/**
* Intended to avoid restrictions with passing null-containing strings to bcrypt functions like password_hash.
*
* In particular, prevents the error "Bcrypt password must not contain null character" when passing output of
* random_bytes directly to password_hash.
*
* @see https://github.com/php/php-src/commit/6a5c04d01d
* @see https://stackoverflow.com/questions/78646470
* @license CC-0
*
* @param int $size
* @return string
* @throws \Random\RandomException
*/
function random_bytes_no_null(int $size): string
{
$str = '';
while (true) {
// this while(true) call structure ensures random_bytes is called at least once.
// this keeps original exceptions intact, like:
// "ValueError random_bytes(): Argument #1 ($length) must be greater than 0."
$str .= str_replace("\0", '', random_bytes($size));
if (strlen($str) >= $size) {
break;
}
}
$str = substr($str, 0, $size);
assert(strlen($str) === $size);
assert(str_contains("test\0", "\0") === true, 'sanity: proper haystack/needle order');
assert(str_contains($str, "\0") === false);
return $str;
}
assert((function (): bool {
try {
random_bytes_no_null(0);
return false;
} catch (\ValueError) {
// we expect "random_bytes(): Argument #1 ($length) must be greater than 0."
return true;
}
})());
assert((function (): bool {
for ($i = 1; $i < 2048; $i++) {
random_bytes_no_null($i);
}
return true;
})());
The output from the above random_bytes_no_null
function can safely be passed to password_hash
after the April 9th, 2024 restriction was added. This function reduces the characterset from \x00-\xFF
to \x01-\xFF
. Test this function by running with assertions enabled: php -d zend.assertions=1 random_bytes_no_null.php