I need an encryption system with a super simple API. I'd prefer something as simple as the following. The output should also be browser-friendly and ideally url-friendly.
$encrypted=encrypt(data);
$decrypted=decrypt(encrypted);
I'm not a cryptographer, however I have come up with the following to achieve this.
<?php
declare(strict_types = 1);
class EasyCrypt
{
// format: easycryptstart_version_(bytes_of_b64bin)_b64bin_easycryptend
// b64bin: base64urlencode(bin)
// bin: versionspecific
// V1 versionspecific: IV+aes128ctr(encryption_key=hkey,csum+inner_length+data+padding)
// V1 hkey: substr(sha256(key),16); // sha256 is used as a key compressor/expander
// V1 inner_length: little_endian_uint64(strlen(data))
// V1 csum: substr(sha256(inner_length+data+padding),14)
// V1 padding: null-bytes until strlen(csum+inner_length+data+padding) is divisible by 16 bytes (128 bits), (16-(size%16))%16
// generate secure key: cat /dev/urandom | head --bytes=15 | base64
private const EASY_ENCRYPTION_KEY = "CHANGEME";
private const V1_IV_LENGTH = 16;
private const V1_ENCRYPT_ALGO = 'aes-128-ctr';
private const V1_HASH_ALGO = 'sha256';
private const V1_HASH_TRUNCATE_LENGTH = 14;
public static function encryptEasy(string $data): string
{
return self::encrypt($data, self::EASY_ENCRYPTION_KEY);
}
public static function decryptEasy(string $data, string &$decryptionError = null): ?string
{
return self::decrypt($data, self::EASY_ENCRYPTION_KEY, $decryptionError);
}
public static function encrypt(string $data, string $encryption_key): string
{
$version = 1;
$prefix = "easycryptstart_{$version}_";
$postfix = "_easycryptend";
$ret = self::encryptV1($data, $encryption_key);
$ret = self::base64url_encode($ret);
$ret = $prefix . strlen($ret) . "_" . $ret . $postfix;
return $ret;
}
public static function decrypt(string $data, string $encryption_key, string &$decryptionError = null): ?string
{
// only 1 "version" exist thus far
$version = 1;
$data = str_replace(array(
" ",
"\r",
"\n",
"\t"
), "", $data);
$prefix = "easycryptstart_{$version}_";
$postfix = "_easycryptend";
$prefixpos = strpos($data, $prefix);
if (false === $prefixpos) {
$decryptionError = "prefix not found";
return null;
}
$postfixpos = strpos($data, $postfix, $prefixpos);
if (false === $postfixpos) {
$decryptionError = "postfix not found (even tho prefix was found!)";
return null;
}
$data = substr($data, $prefixpos + strlen($prefix), $postfixpos - ($prefixpos + strlen($prefix)));
$outer_length_end = strpos($data, "_");
if (false === $outer_length_end) {
$decryptionError = "corrupted input, outer length end missing!";
return null;
}
$outer_length = substr($data, 0, $outer_length_end);
$outer_length = filter_var($outer_length, FILTER_VALIDATE_INT);
if (false === $outer_length) {
$decryptionError = "corrupt input, outer_length non-int!";
return null;
}
$data = substr($data, $outer_length_end + strlen("_"));
$dlen = strlen($data);
if ($dlen < $outer_length) {
$decryptionError = "corrupt input, outer length header said {$outer_length} bytes, but only {$dlen} bytes available!";
return null;
}
$data = substr($data, 0, $outer_length);
$data = self::base64url_decode($data);
return self::decryptV1($data, $encryption_key, $decryptionError);
}
private static function decryptV1(string $data, string $encryption_key, string &$decryptionError = null): ?string
{
if (strlen($data) < self::V1_IV_LENGTH) {
$decryptionError = "corrupt input, IV is missing!";
return null;
}
$IV = substr($data, 0, self::V1_IV_LENGTH);
$data = substr($data, self::V1_IV_LENGTH);
// now we have the aes128 data..
if (strlen($data) < 16 || (strlen($data) % 16) !== 0) {
$decryptionError = "corrupted input, after removing IV, data is not a multiple of 16 bytes!";
return null;
}
$hkey = hash(self::V1_HASH_ALGO, $encryption_key, true);
$hkey = substr($hkey, 0, 16);
$data = openssl_decrypt($data, self::V1_ENCRYPT_ALGO, $hkey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $IV);
if (! is_string($data)) {
// should never happen
throw new \RuntimeException("openssl_decrypt failed! wtf!?");
}
if (strlen($data) < self::V1_HASH_TRUNCATE_LENGTH) {
$decryptionError = "corrupt input, after decryption, checksum hash is missing!";
return null;
}
$checksum_supplied_hash = substr($data, 0, self::V1_HASH_TRUNCATE_LENGTH);
$data = substr($data, self::V1_HASH_TRUNCATE_LENGTH);
$checksum_calculated_hash = hash(self::V1_HASH_ALGO, $data, true);
$checksum_calculated_hash = substr($checksum_calculated_hash, 0, self::V1_HASH_TRUNCATE_LENGTH);
if (! hash_equals($checksum_calculated_hash, $checksum_supplied_hash)) {
$decryptionError = "checksum mismatch, possibly wrong decryption key?";
return null;
}
$little_endian_uint64_length = 8;
if (strlen($data) < $little_endian_uint64_length) {
$decryptionError = "after decryption, inner_length header is missing!";
return null;
}
$little_endian_uint64 = substr($data, 0, $little_endian_uint64_length);
$little_endian_uint64 = self::from_little_uint64_t($little_endian_uint64);
$data = substr($data, $little_endian_uint64_length);
$dlen = strlen($data);
if ($dlen < $little_endian_uint64) {
$decryptionError = "inner_length header said {$little_endian_uint64} bytes, but only {$dlen} bytes remaining, and that includes any padding bytes!";
return null;
}
$data = substr($data, 0, $little_endian_uint64);
return $data;
}
private static function encryptV1(string $data, string $encryption_key): string
{
// compress/expand the key so we can accept any encryption key length (instead of the 16 bytes key required by aes-128)
$hkey = hash(self::V1_HASH_ALGO, $encryption_key, true);
$hkey = substr($hkey, 0, 16);
$iv = random_bytes(self::V1_IV_LENGTH);
$inner_length_bytes = self::to_little_uint64_t(strlen($data));
$ret = $inner_length_bytes;
$ret .= $data;
$padding_length = self::V1_HASH_TRUNCATE_LENGTH + strlen($ret);
$padding_length = (16 - ($padding_length % 16)) % 16;
$ret .= str_repeat("\x00", $padding_length);
$csum = hash(self::V1_HASH_ALGO, $ret, true);
$csum = substr($csum, 0, self::V1_HASH_TRUNCATE_LENGTH);
$ret = $csum . $ret;
$str = openssl_encrypt($ret, self::V1_ENCRYPT_ALGO, $hkey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
if (! is_string($str)) {
// should never happen
throw new \RuntimeException("openssl_encrypt failed! wtf!?");
}
$str = $iv . $str;
return $str;
}
private static function to_uint8_t(int $i): string
{
return pack('C', $i);
}
private static function from_uint8_t(string $i): int
{
// ord($i) , i know.
$arr = unpack("Cuint8_t", $i);
return $arr['uint8_t'];
}
private static function to_little_uint64_t(int $i): string
{
return pack('P', $i);
}
private static function from_little_uint64_t(string $i): int
{
$arr = unpack('Puint64_t', $i);
return $arr['uint64_t'];
}
private static function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64url_decode($data)
{
return base64_decode(strtr($data, '-_', '+/'));
}
}
Example usage:
$data = "Hello World!"; // . random_bytes(10*1024*1024);
$decryptionError = "";
$encrypted = EasyCrypt::encryptEasy($data);
$decrypted = EasyCrypt::decryptEasy($encrypted, $decryptionError);
$pretty = [
"data to encrypt" => $data,
"encrypted" => $encrypted,
"decrypted successfully" => $decrypted === $data,
"decryption error" => $decryptionError
];
var_export($pretty);
$ php EasyCrypt.php | more
array (
'data to encrypt' => 'Hello World!',
'encrypted' => 'easycryptstart_1_86_LvBV6n3yLY-sH3vdhjzIZmbAm56s7VEZ9ah0wh5z4p9-rhJBaIDmOQYaWOTuRSei7yfmXJ6HTbqgvBaQJsQdMg_easycryptend',
'decrypted successfully' => true,
'decryption error' => '',
)