While working on sodium_compat, our pure-PHP implementation of libsodium, it has come to our attention that a lot of the engineering decisions we've made to minimize the risk of side-channels aren't well-known outside of our development team. In the interest of transparency, as well as promoting a better understanding among the PHP developer community, we'd like to take this opportunity to document our methods.
Important: Because sodium_compat has not been audited yet, this should not be considered exhaustive. There may be additional steps that are necessary for producing secure cryptography code in PHP.
This page should be considered supplementary to the existing rules for programming cryptography code in C.
The Zeroth Rule of PHP Cryptography
If you can avoid writing cryptography code in PHP, then don't write it in PHP.
Use a reputable PHP extension (such as libsodium) instead.
For example, sodium_compat implements the 0th rule of PHP Cryptography by offloading to libsodium (via core if PHP 7.2+, PECL otherwise) if it's available.
Easy Wins for PHP Cryptography Code
Design your API to Prevent It From Failing Open
There are three ways to handle an unexpected condition in the middle of your library:
- Fatal error: The application terminates unavoidably.
- Non-fatal error: Fail silently, probably resulting in a catastrophic security failure.
- Throw an Exception (or Error class): What you probably want.
Throwing an exception allows you to simultaneously default to failing closed (like with a fatal error) while also allowing a cognizant developer to choose to handle the exception their own way (like with a non-fatal error). A lazy implementation is secure by default, but it still empowers developers to, from their perspective, avoid unexpected crashes.
Do NOT just return FALSE
or NULL
and expect your users to check the return value.
Compare Strings in Constant-Time
If you're using a supported version of PHP, this means you only need to use hash_equals()
.
Otherwise, you need a polyfill. (If you're a WordPress developer and happen to be wondering: Yes, they provide a polyfill for you.)
Some developers object to the implementation of hash_equals()
because they worry about leaking the length of the strings being compared. This is a total non-issue and it wouldn't be worth remarking on if it wasn't so common.
The length of a HMAC-SHA-256 output is not a secret, and if the security of your application depends on obfuscating this obvious information, your protocol is probably insecure in a lot of other ways.
Use Strong Randomness
If you're using PHP 7 and above, just use random_bytes()
and random_int()
. If you're using PHP 5, import random_compat to your project and then use random_bytes()
and random_int()
as if you were on PHP 7.
try {
$data = random_bytes(32);
} catch (Error $ex) {
// You made a mistake.
} catch (Exception $ex) {
// Cannot access the OS's CSPRNG. Handle this however you deem appropriate.
}
It may be tempting to cry foul here, since we wrote random_compat and are recommending it in a blog post.
However, random_compat is one of the most widely used PHP libraries with about 20 million installs on Packagist as of this writing (which does not count WordPress 4.4+ websites). It's used by virtually every serious PHP framework (Symfony, Laravel, Zend) that still supports PHP 5.
You will be hard-pressed to find a security expert familiar with PHP 5 that doesn't recommend it.
DO NOT USE these PHP functions for cryptography or security purposes:
-
rand()
-
mt_rand()
-
uniqid()
-
lcg_value()
-
gmp_random_*()
- Messy hacks like
hash($algo, /* ...stuff... */ microtime() /* ...stuff... */)
PHP Cryptography: The Hard Parts
So far, everything covered is generally universalizable: You need a sane API, strong randomness, and constant-time string comparison to do secure cryptography in every programming language. This is where things get a bit tricky and PHP-specific.
Ensure String Offsets are Bytes, not Characters
PHP has a little-known feature called function overloading, which changes the behavior of some functions (but most notable substr()
and strlen()
) to operate on characters rather than bytes.
Consider the following example:
<?php
/**
* @param string $knownString
* @param string $userString
* @return bool
*/
function unsafe_hash_equals($knownString, $userString)
{
$kLen = strlen($knownString);
$uLen = strlen($userString);
if ($kLen !== $uLen) {
return false;
}
$result = 0;
for ($i = 0; $i < $kLen; $i++) {
$result |= (ord($knownString[$i]) ^ ord($userString[$i]));
}
// They are only identical strings if $result is exactly 0...
return 0 === $result;
}
// 8 chars but 32 bytes
$hashA = "\xF0\x9D\x92\xB3" . "\xF0\x9D\xA5\xB3" .
"\xF0\x9D\x92\xB3" . "\xF0\x9D\xA5\xB3" .
"\xF0\x9D\x92\xB3" . "\xF0\x9D\xA5\xB3" .
"\xF0\x9D\x92\xB3" . "\xF0\x9D\xA5\xB3";
$hashB = "\xF0\x9D\x92\xB3" . "\xF0\x9D\xA5\xB3" .
"\xF0\xAD\x9F\xC0" . "\xF0\xAD\x9F\xC0" .
"\xF0\xAD\x9F\xC0" . "\xF0\xAD\x9F\xC0" .
"\xF0\xAD\x9F\xC0" . "\xF0\xAD\x9F\xC0";
var_dump(unsafe_hash_equals($hashA, $hashB));
If you run this code with mbstring.func_overload
set to 0, it returns FALSE
as you'd expect. Clearly, those two strings are not the same value. However, if you set mbstring.func_overload
to 2, 3, or 7, it suddenly returns TRUE
.
To guarantee that PHP is treating strings as raw binary strings (where 1 byte = 1 character), use the multi-byte functions (mb_strlen()
, mb_substr()
) instead, taking care to specify 8bit
as the charset.
- $kLen = strlen($knownString);
- $uLen = strlen($userString);
+ $kLen = mb_strlen($knownString, '8bit');
+ $uLen = mb_strlen($userString, '8bit');
To be totally clear: there's a little more to it than that if you're writing software that may run on machines that don't have the mbstring extension installed. Fortunately, the mbstring.func_overload
option only does anything if mbstring is installed, so if it's not, you can be sure that the original functions behave as expected.
Safely Convert Integers to Characters Without Table Lookups
Regardless of the cryptography primitives you're working with, one operation that you'll certainly be implementing is converting between integers and strings.
This sounds like a trivial undertaking. After all, chr()
and ord()
are provided by the language for this exact purpose. However, you should be aware that the way chr()
is implemented makes it unsuitable for cryptography purposes.
First, let's look at this code:
if (CG(one_char_string)[c]) {
ZVAL_INTERNED_STR(return_value, CG(one_char_string)[c]);
}
It helps to know that one_char_string
is populated here, and maps the integers 0 - 255 to their ASCII equivalent.
Why does this matter? Because if the data you're encoding or decoding is secret (either the plaintext or a secret key), using chr()
means that you are using table look-ups indexed by secret data, which creates a textbook example of a cache-timing side-channel.
To side-step this, we can use pack('C', $int);
to safely convert an integer into an ASCII character, since pack()
doesn't use any sort of table look-up.
Using Constant-Time Encoding Routines
We've covered this before when introducing our constant-time RFC 4648 encoding library.
The way PHP currently implements bin2hex()
, hex2bin()
, etc. is likely prone to cache-timing information leaks, since it is (once again) using your (possibly secret) data as indexes in table look-ups.
We could walk you through the solution in painstaking detail, or you can just use the library we already wrote and not worry about these details.
Ensure Multiplication is Constant-Time
Yes, you read that right. Based on research into constant-time multiplication by Kudelski Security and information pooled by the BearSSL project, there are certainly some platforms for which multiplying two integers may take longer depending on the values of the two integers.
Some cases which integer multiplication can leak timing information include:
- If one or both of the numbers is 1 or 0
- If one or both of the numbers is a power of 2
- If both numbers are above or below a certain value (65,536 for ARM Cortex-M3)
Unfortunately for developers, whether or not multiplication is vulnerable depends entirely on hardware (i.e. the processor).
Some platforms don't leak anything. Other platforms leak enough information to steal cryptographic secrets (see the Kudelski Security post mentioned previously for an attack on Curve25519 when compiled on MSVC).
To work around this, we wrote a constant-time implementation of integer multiplication in pure PHP for sodium_compat. Its contents are reproduced below, with inline annotations (beginning with a #
):
/**
* Multiply two integers in constant-time
*
* @param int $a
* @param int $b
* @return int
*/
public static function mul($a, $b)
{
# Important detail: If you know your platform is secure, you can opt
# out of our implementation in favor of just multiplying integers the
# old fashioned way by setting a static variable to true at run-time.
#
# It defaults to FALSE and we advise no one set it to TRUE unless you
# have irrefutable proof that this is safe for your operating environment.
if (ParagonIE_Sodium_Compat::$fastMult) {
return (int) ($a * $b);
}
# Optimization: Cache this value after the first run
static $size = null;
if (!$size) {
# On 64-bit platforms, this will be 63.
# On 32-bit platforms, this will be 31.
$size = (PHP_INT_SIZE << 3) - 1;
}
$c = 0;
#
# Equivalent to: $mask = ($b < 0) ? -1 : 0;
#
# -1 in binary is all 1 bits; 0 in binary is all 0 bits.
$mask = -(($b >> $size) & 1);
#
# We're stripping off the negative sign so we can safely
# use bitshift operations and eventually get 0.
$b = ($b & ~$mask) | ($mask & -$b);
#
# This is the actual multiplication loop.
# It always runs ($size + 1) times, and consists of
# addition and bitwise operators.
for ($i = $size; $i >= 0; --$i) {
# Equivalent to: if ($b & 1) $c += $a;
$c += (int) ($a & -($b & 1));
# Double $a
$a <<= 1;
# Halve $b
$b >>= 1;
}
# Equivalent to: if ($mask < 0) { $c *= -1; }
#
# This just reintroduces the negative sign again if $b had
# a negative value.
$c = ($c & ~$mask) | ($mask & -$c);
return (int) $c;
}
Thus, you can eliminate timing side-channels introduced by integer multiplication from your PHP code.
The Impossible
Some cryptography best practices are simply not possible. To wit: PHP doesn't allow you to perform direct memory management, so zeroing out memory buffers is not possible.
Furthermore, if a vulnerability is introduced somewhere else in the PHP interpreter (for example, via OpCache), there's very little (if anything) you can do to mitigate it from a PHP script.
The Ambitious
Are you thinking about writing your own cryptography code in PHP? Have you inherited a legacy application where someone decided to be inventive with their strategy for handling sensitive data? Or are you just interested in seeing if an existing PHP cryptography library you want to use is secure?
We consult, we develop, we pen-test, and we also audit cryptography code.