Using libsodium to upgrade the comment key system
I've blogged about the comment key system I utilise on this blog to prevent spam before (see also). Today, I've given it another upgrade to make it harder for spammers to fake a comment key!
In the last post, I transformed the comment key with a number of reversible operations - including a simple XOR password system. This is, of course, very insecure - especially since an attacker knows (or can at least guess) the content of the encrypted key, making it trivial (I suspect) to guess the password used for 'encryption'.
The solution here, obviously, is to utilise a better encryption system. Since it's the 5th November and I'm not particularly keen on my website going poof like the fireworks tonight, let's do something about it! PHP 7.2+ comes with native libsodium support (those still using older versions of PHP can still follow along! Simply install the PECL module). libsodium bills itself as
A modern, portable, easy to use crypto library.
After my experiences investigating it, I'd certainly say that's true (although the official PHP documentation could do with, erm, existing). I used this documentation instead instead - it's quite ironic because I have actually base64-encoded the password.......
Anyway, after doing some digging I found the quick reference, which explains how you can go about accomplishing common tasks with libsodium. For my comment key system, I want to encrypt my timestamp with a password - so I wanted the sodium_crypto_secretbox()
and its associated sodium_crypto_secretbox_open()
functions.
This pair of functions, when used together, implement a secure symmetric key encryption system. In other words, they securely encrypt a lump of data with a password. They do, however, have 2 requirements that must be taken care of. Firstly, the password must be of a specific length. This is easy enough to accomplish, as PHP is kind enough to tell us how long it needs to be:
/**
* Generates a new random comment key system password.
* @return string The base64-encoded password.
*/
function key_generate_password() {
return base64_encode_safe(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
}
Easy-peasy! base64_encode_safe
isn't a built-in function - it's a utility function I wrote that we'll need later. For consistency, I've used it here too. Here it is, along with its counterpart:
/**
* Encodes the given data with base64, but replaces characters that could
* get mangled in transit with safer equivalents.
* @param mixed $data The data to encode.
* @return string The data, encoded as transmit-safe base64.
*/
function base64_encode_safe($data) {
return str_replace(["/", "+"], ["-", "_"], base64_encode($data));
}
/**
* Decodes data encoded with base64_encode_safe().
* @param mixed $base64 The data to decode.
* @return string The data, decoded from transmit-safe base64.
*/
function base64_decode_safe($base64) {
return base64_decode(str_replace(["-", "_"], ["/", "+"], $base64));
}
With that taken care of, we can look at the other requirement: a nonce. Standing for Number used ONCE, it's a sequence of random bytes that's used by the encryption algorithm. We don't need to keep it a secret, but we do need to to decrypt the data again at the other end, in addition to the password - and we do need to ensure that we generate a new one for every comment key. Thankfully, this is also easy to do in a similar manner to generating a password:
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
With everything in place, we can look at upgrading the comment key generator itself. It looks much simpler now:
/**
* Generates a new comment key.
* Note that the 'encryption' used is not secure - it's just simple XOR!
* It's mainly to better support verification in complex setups and
* serve as a nother annoying hurdle for spammers.
* @param string $pass The password to encrypt with. Should be a base64-encoded string from key_generate_password().
* @return string A new comment key stamped at the current time.
*/
function key_generate($pass) {
$pass = base64_decode_safe($pass);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypt = sodium_crypto_secretbox(
strval(time()), // The thing we want to encrypt
$nonce, // The nonce
$pass // The password to encrypt with
);
return base64_encode_safe($nonce.$encrypt);
}
I bundle the nonce with the encrypted data here to ensure that the system continues to be stateless (i.e. we don't need to store any state information about a user on the server). I also encode the encrypted string with base64, as the encrypted strings contain lots of nasty characters (it's actually a binary byte array I suspect). This produces keys like this:
BOqDvr26XY9s8PhlmIZMIp6xCOZyfsh6J05S4Jp2cY3bL8ccf_oRgRMldrmzKk6RrnA=
Tt8H81tkJEqiJt-RvIstA_vz13LS8vjLgriSAvc1n5iKwHuEKjW93IMITikdOwr5-NY=
5NPvHg-l1GgcQ9ZbThZH7ixfKGqAtSBr5ggOFbN_wFRjo3OeJSWAcFNhQulYEVkzukI=
They are a bit long, but not unmanageable. In theory, I could make it a bit shorter by avoiding converting the integer output from time()
to a string, but in my testing it didn't make much difference. I suspect that there's some sort of minimum length to the output string for some (probably good) reason.
php > var_dump(sodium_crypto_secretbox(time(), random_bytes(24), random_bytes(32)));
string(26) "GW$:���ߌ@]�+1b��������d%"
php > var_dump(sodium_crypto_secretbox(strval(time()), random_bytes(24), random_bytes(32)));
string(26) ":_}0H �E°9��Kn�p��ͧ��"
Now that we're encrypting comment keys properly, it isn't much good unless we can decrypt them as well! Let's do that too. The first step is to decode the base64 and re-split the nonce from the encrypted data:
$pass = base64_decode_safe($pass);
$key_enc_raw = base64_decode_safe($key);
$nonce = mb_substr($key_enc_raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, "8bit");
$key_enc = mb_substr($key_enc_raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, "8bit");
This is derived from the example code. With the 2 separated once more, we can decrypt the key:
$key_dec = sodium_crypto_secretbox_open($key_enc, $nonce, $pass);
Apparently, according to the example code on the website I linked to above, if the return value isn't a string then the decryption failed. We should make sure we handle that when returning:
if(!is_string($key_dec))
return null;
return intval($key_dec);
That completes the decryption code. Here is in full:
/**
* Decodes the given key.
* @param string $key The key to decode.
* @param string $pass The password to decode it with.
* @return int|null The time that the key was generated, or null if the provided key was invalid.
*/
function key_decode($key, $pass) {
$pass = base64_decode_safe($pass);
$key_enc_raw = base64_decode_safe($key);
$nonce = mb_substr($key_enc_raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, "8bit");
$key_enc = mb_substr($key_enc_raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, "8bit");
$key_dec = sodium_crypto_secretbox_open($key_enc, $nonce, $pass);
if(!is_string($key_dec)) return null;
return intval($key_dec);
}
Explicitly returning null
in key_decode()
requires a small change to key_verify()
, in order to prevent it from thinking that a key is really old if the decryption fails (null
is treated as 0 in arithmetic operations, apparently). Let's update key_verify()
to handle that:
/**
* Verifies a key.
* @param string $key The key to decode and verify.
* @param string $pass The password to decode the key with.
* @param int $min_age The minimum required age for the key.
* @param int $max_age The maximum allowed age for the key.
* @return bool Whether the key is valid or not.
*/
function key_verify($key, $pass, $min_age, $max_age) {
$key_time = key_decode($key, $pass);
if($key_time == null) return false;
$age = time() - $key_time;
return $age >= $min_age && $age <= $max_age;
}
A simple check is all that's needed. With that, the system is all updated! Time to generate a new password with the provided function and put it to use. You can do that directly from the PHP REPL (php -a
):
php > require("lib/comment_system/comment_key.php");
php > var_dump(key_generate_password()); string(44) "S0bJpph5htdaKHZdVfo9FB6O4jOSzr3xZZ94c2Qcn44="
Obviously, this isn't the password I'm using on my blog :P
I've got a few more ideas I'd like to play around with to deter spammers even more whilst maintaining the current transparency - so you may see another post soon on this subject :-)
With everything complete, here's the complete script:
(Above: The full comment key system code. Can't see it? Check it out on GitHub Gist here.)
Found this useful? Got an improvement? Confused about something? Comment below!
Sources and Further Reading