diff --git a/benchmark.php b/benchmark.php index 1f3f475..c7b58ba 100644 --- a/benchmark.php +++ b/benchmark.php @@ -1,5 +1,6 @@ > 4; + $hex .= \chr(87 + $b + ((($b - 10) >> 8) & ~38)); + $hex .= \chr(87 + $c + ((($c - 10) >> 8) & ~38)); + } + return $hex; + } + + /** + * Convert a hexadecimal string into a binary string without cache-timing + * leaks + * + * @param string $hex_string + * @return string (raw binary) + */ + public static function hexToBin($hex_string) + { + $hex_pos = 0; + $bin = ''; + $hex_len = self::ourStrlen($hex_string); + $state = 0; + $c_acc = 0; + + while ($hex_pos < $hex_len) { + $c = \ord($hex_string[$hex_pos]); + $c_num = $c ^ 48; + $c_num0 = ($c_num - 10) >> 8; + $c_alpha = ($c & ~32) - 55; + $c_alpha0 = (($c_alpha - 10) ^ ($c_alpha - 16)) >> 8; + if (($c_num0 | $c_alpha0) === 0) { + throw new \RangeException( + 'Crypto::hexToBin() only expects hexadecimal characters' + ); + } + $c_val = ($c_num0 & $c_num) | ($c_alpha & $c_alpha0); + if ($state === 0) { + $c_acc = $c_val * 16; + } else { + $bin .= \chr($c_acc | $c_val); + } + $state = $state ? 0 : 1; + ++$hex_pos; + } + return $bin; + } + + /** + * Increment a counter (prevent nonce reuse) + * + * @param string $ctr - raw binary + * @param int $inc - how much? + * + * @return string (raw binary) + */ + public static function incrementCounter($ctr, $inc, &$config) + { + static $ivsize = null; + if ($ivsize === null) { + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + } + + /** + * We start at the rightmost byte (big-endian) + * So, too, does OpenSSL: http://stackoverflow.com/a/3146214/2224584 + */ + + for ($i = $ivsize - 1; $i >= 0; --$i) { + $c = \ord($ctr[$i]); + + $ctr[$i] = \chr(($c + $inc) & 0xFF); + if (($c + $inc) <= 255) { + // We don't need to keep incrementing to the left unless we exceed 255 + break; + } + $inc = ($inc >> 8) & ~0; + ++$inc; + } + return $ctr; + } + + /** + * Returns a random binary string of length $octets bytes. + * + * @param int $octets + * @return string (raw binary) + * @throws Ex\CannotPerformOperationException + */ + protected static function secureRandom($octets) + { + self::ensureFunctionExists('openssl_random_pseudo_bytes'); + $secure = false; + $random = \openssl_random_pseudo_bytes($octets, $secure); + if ($random === false || $secure === false) { + throw new Ex\CannotPerformOperationException( + "openssl_random_pseudo_bytes() failed." + ); + } + return $random; + } + /** + * Use HKDF to derive multiple keys from one. + * http://tools.ietf.org/html/rfc5869 + * + * @param string $hash Hash Function + * @param string $ikm Initial Keying Material + * @param int $length How many bytes? + * @param string $info What sort of key are we deriving? + * @param string $salt + * @return string + * @throws Ex\CannotPerformOperationException + */ + protected static function HKDF($hash, $ikm, $length, $info = '', $salt = null, $config = null) + { + if (empty($config)) { + $valid = 0; + $config = self::getVersionConfig(1, 0, $valid); + } + // Find the correct digest length as quickly as we can. + $digest_length = $config['MAC_BYTE_SIZE']; + if ($hash != $config['HASH_FUNCTION']) { + $digest_length = self::ourStrlen(\hash_hmac($hash, '', '', true)); + } + // Sanity-check the desired output length. + if (empty($length) || !\is_int($length) || + $length < 0 || $length > 255 * $digest_length) { + throw new Ex\CannotPerformOperationException( + "Bad output length requested of HKDF." + ); + } + + // "if [salt] not provided, is set to a string of HashLen zeroes." + if (\is_null($salt)) { + $salt = \str_repeat("\x00", $digest_length); + } + + // HKDF-Extract: + // PRK = HMAC-Hash(salt, IKM) + // The salt is the HMAC key. + $prk = \hash_hmac($hash, $ikm, $salt, true); + + // HKDF-Expand: + + // This check is useless, but it serves as a reminder to the spec. + if (self::ourStrlen($prk) < $digest_length) { + throw new Ex\CannotPerformOperationException(); + } + + // T(0) = '' + $t = ''; + $last_block = ''; + for ($block_index = 1; self::ourStrlen($t) < $length; ++$block_index) { + // T(i) = HMAC-Hash(PRK, T(i-1) | info | 0x??) + $last_block = \hash_hmac( + $hash, + $last_block . $info . \chr($block_index), + $prk, + true + ); + // T = T(1) | T(2) | T(3) | ... | T(N) + $t .= $last_block; + } + + // ORM = first L octets of T + $orm = self::ourSubstr($t, 0, $length); + if ($orm === false) { + throw new Ex\CannotPerformOperationException(); + } + return $orm; + } + + /** + * Verify a HMAC without crypto side-channels + * + * @staticvar boolean $native Use native hash_equals()? + * @param string $expected string (raw binary) + * @param string $given string (raw binary) + * @return boolean + * @throws Ex\CannotPerformOperation + */ + protected static function hashEquals($expected, $given) + { + static $native = null; + if ($native === null) { + $native = \function_exists('hash_equals'); + } + if ($native) { + return \hash_equals($expected, $given); + } + static $config = null; + if ($config === null) { + $valid = 0; + $config = self::getCoreVersionConfig(1, 0, $valid); + } + + // We can't just compare the strings with '==', since it would make + // timing attacks possible. We could use the XOR-OR constant-time + // comparison algorithm, but I'm not sure if that's good enough way up + // here in an interpreted language. So we use the method of HMACing the + // strings we want to compare with a random key, then comparing those. + + // NOTE: This leaks information when the strings are not the same + // length, but they should always be the same length here. Enforce it: + if (self::ourStrlen($expected) !== self::ourStrlen($given)) { + throw new Ex\CannotPerformOperationException(); + } + + $blind = self::createNewRandomKey(); + $message_compare = hash_hmac($config['HASH_FUNCTION'], $given, $blind); + $correct_compare = hash_hmac($config['HASH_FUNCTION'], $expected, $blind); + return $correct_compare === $message_compare; + } + /** + * If the constant doesn't exist, throw an exception + * + * @param string $name + * @throws Ex\CannotPerformOperationException + */ + protected static function ensureConstantExists($name) + { + if (!\defined($name)) { + throw new Ex\CannotPerformOperationException(); + } + } + + /** + * If the functon doesn't exist, throw an exception + * + * @param string $name Function name + * @throws Ex\CannotPerformOperationException + */ + protected static function ensureFunctionExists($name) + { + if (!\function_exists($name)) { + throw new Ex\CannotPerformOperationException(); + } + } + + /* + * We need these strlen() and substr() functions because when + * 'mbstring.func_overload' is set in php.ini, the standard strlen() and + * substr() are replaced by mb_strlen() and mb_substr(). + */ + + /** + * Safe string length + * + * @staticvar boolean $exists + * @param string $str + * @return int + */ + protected static function ourStrlen($str) + { + static $exists = null; + if ($exists === null) { + $exists = \function_exists('mb_strlen'); + } + if ($exists) { + $length = \mb_strlen($str, '8bit'); + if ($length === false) { + throw new Ex\CannotPerformOperationException(); + } + return $length; + } else { + return \strlen($str); + } + } + + /** + * Safe substring + * + * @staticvar boolean $exists + * @param string $str + * @param int $start + * @param int $length + * @return string + */ + protected static function ourSubstr($str, $start, $length = null) + { + static $exists = null; + if ($exists === null) { + $exists = \function_exists('mb_substr'); + } + if ($exists) + { + // mb_substr($str, 0, NULL, '8bit') returns an empty string on PHP + // 5.3, so we have to find the length ourselves. + if (!isset($length)) { + if ($start >= 0) { + $length = self::ourStrlen($str) - $start; + } else { + $length = -$start; + } + } + + return \mb_substr($str, $start, $length, '8bit'); + } + + // Unlike mb_substr(), substr() doesn't accept NULL for length + if (isset($length)) { + return \substr($str, $start, $length); + } else { + return \substr($str, $start); + } + } + + /** + * Take a 4-byte header and get meaningful version information out of it. + * Common configuration options should go in Core.php + * + * DO NOT CHANGE THESE VALUES! + * + * We spent *weeks* testing this code, making sure it is as perfect and + * correct as possible. Are you going to do the same after making your + * changes? Probably not. Besides, any change to these constants will break + * the runtime tests, which are extremely important for your security. + * You're literally millions of times more likely to screw up your own + * security by changing something here than you are to fall victim to an + * 128-bit key brute-force attack. You're also breaking your own + * compatibility with future updates to this library, so you'll be left + * vulnerable if we ever find a security bug and release a fix. + * + * So, PLEASE, do not change these constants. + * + * @param int $major + * @param int $minor + * @param ref $valid + * @return type + */ + protected static function getCoreVersionConfig($major, $minor, &$valid) + { + if ($major === 2) { + switch ($minor) { + case 0: + return [ + 'BLOCK_SIZE' => 16, + 'KEY_BYTE_SIZE' => 16, + 'SALT_SIZE' => 16, + 'HASH_FUNCTION' => 'sha256', + 'MAC_BYTE_SIZE' => 32, + 'ENCRYPTION_INFO' => 'DefusePHP|KeyForEncryption', + 'AUTHENTICATION_INFO' => 'DefusePHP|KeyForAuthentication' + ]; + default: + $valid |= 0xFF; + break; + } + } elseif ($major === 1) { + $valid |= 0xFF; // Set to a nonzero value to mark it as invalid + return [ + 'BLOCK_SIZE' => 16, + 'KEY_BYTE_SIZE' => 16, + 'SALT_SIZE' => null, + 'HASH_FUNCTION' => 'sha256', + 'MAC_BYTE_SIZE' => 32, + 'ENCRYPTION_INFO' => 'DefusePHP|KeyForEncryption', + 'AUTHENTICATION_INFO' => 'DefusePHP|KeyForAuthentication' + ]; + } + $valid |= 0xFF; // Set to a nonzero value to mark it as invalid + } +} diff --git a/src/Crypto.php b/src/Crypto.php index 986d39a..6d6cad4 100755 --- a/src/Crypto.php +++ b/src/Crypto.php @@ -5,7 +5,7 @@ /* * PHP Encryption Library - * Copyright (c) 2014-2015, Taylor Hornby + * Copyright (c) 2014-2015, Taylor Hornby * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -30,85 +30,88 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ -final class Crypto +final class Crypto extends Core { - // Ciphertext format: [____HMAC____][____IV____][____CIPHERTEXT____]. - - /* DO NOT CHANGE THESE CONSTANTS! - * - * We spent *weeks* testing this code, making sure it is as perfect and - * correct as possible. Are you going to do the same after making your - * changes? Probably not. Besides, any change to these constants will break - * the runtime tests, which are extremely important for your security. - * You're literally millions of times more likely to screw up your own - * security by changing something here than you are to fall victim to an - * 128-bit key brute-force attack. You're also breaking your own - * compatibility with future updates to this library, so you'll be left - * vulnerable if we ever find a security bug and release a fix. - * - * So, PLEASE, do not change these constants. - */ + // Ciphertext format: [____VERSION____][____HMAC____][____IV____][____CIPHERTEXT____]. + // Legacy format: [____HMAC____][____IV____][____CIPHERTEXT____]. const CIPHER_METHOD = 'aes-128-cbc'; - const KEY_BYTE_SIZE = 16; - const HASH_FUNCTION = 'sha256'; - const MAC_BYTE_SIZE = 32; - const ENCRYPTION_INFO = 'DefusePHP|KeyForEncryption'; - const AUTHENTICATION_INFO = 'DefusePHP|KeyForAuthentication'; + + const LEGACY_VERSION = "\xD3\xF5\x01\x00"; /** * Use this to generate a random encryption key. - * + * * @return string */ public static function createNewRandomKey() { - self::runtimeTest(); - return self::secureRandom(self::KEY_BYTE_SIZE); + $config = self::getVersionConfig(self::VERSION); + return self::secureRandom($config['KEY_BYTE_SIZE']); } /** - * * Encrypts a message. + * * $plaintext is the message to encrypt. * $key is the encryption key, a value generated by CreateNewRandomKey(). * You MUST catch exceptions thrown by this function. See docs above. - * + * * @param string $plaintext * @param string $key + * @param boolean $raw_binary * @return string * @throws Ex\CannotPerformOperationException */ - public static function encrypt($plaintext, $key) + public static function encrypt($plaintext, $key, $raw_binary = false) { self::runtimeTest(); + $config = self::getVersionConfig(parent::VERSION); - if (self::ourStrlen($key) !== self::KEY_BYTE_SIZE) - { + if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { throw new Ex\CannotPerformOperationException("Key is the wrong size."); } + $salt = self::secureRandom($config['SALT_SIZE']); // Generate a sub-key for encryption. - $keysize = self::KEY_BYTE_SIZE; - $ekey = self::HKDF(self::HASH_FUNCTION, $key, $keysize, self::ENCRYPTION_INFO); + $ekey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['ENCRYPTION_INFO'], + $salt, + $config + ); + + // Generate a sub-key for authentication and apply the HMAC. + $akey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['AUTHENTICATION_INFO'], + $salt, + $config + ); // Generate a random initialization vector. self::ensureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length(self::CIPHER_METHOD); - if ($ivsize === FALSE || $ivsize <= 0) { + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + if ($ivsize === false || $ivsize <= 0) { throw new Ex\CannotPerformOperationException( "Could not get the IV length from OpenSSL" ); } $iv = self::secureRandom($ivsize); - $ciphertext = $iv . self::plainEncrypt($plaintext, $ekey, $iv); + $ciphertext = $salt . $iv . self::plainEncrypt($plaintext, $ekey, $iv, $config); + $auth = \hash_hmac($config['HASH_FUNCTION'], parent::VERSION . $ciphertext, $akey, true); - // Generate a sub-key for authentication and apply the HMAC. - $akey = self::HKDF(self::HASH_FUNCTION, $key, self::KEY_BYTE_SIZE, self::AUTHENTICATION_INFO); - $auth = \hash_hmac(self::HASH_FUNCTION, $ciphertext, $akey, true); - $ciphertext = $auth . $ciphertext; + // We're now appending the header as of 2.00 + $ciphertext = parent::VERSION . $auth . $ciphertext; - return $ciphertext; + if ($raw_binary) { + return $ciphertext; + } + return self::binToHex($ciphertext); } /** @@ -116,44 +119,163 @@ public static function encrypt($plaintext, $key) * $ciphertext is the ciphertext to decrypt. * $key is the key that the ciphertext was encrypted with. * You MUST catch exceptions thrown by this function. See docs above. - * + * * @param string $ciphertext * @param string $key + * @param boolean $raw_binary * @return type * @throws Ex\CannotPerformOperationException * @throws Ex\InvalidCiphertextException */ - public static function decrypt($ciphertext, $key) + public static function decrypt($ciphertext, $key, $raw_binary = false) { self::runtimeTest(); + if (!$raw_binary) { + $ciphertext = self::hexToBin($ciphertext); + } + + // Grab the header tag + $version = self::ourSubstr($ciphertext, 0, parent::HEADER_VERSION_SIZE); + + // Load the configuration for this version + $config = self::getVersionConfig($version); + + // Now let's operate on the remainder of the ciphertext as normal + $ciphertext = self::ourSubstr($ciphertext, parent::HEADER_VERSION_SIZE, null); // Extract the HMAC from the front of the ciphertext. - if (self::ourStrlen($ciphertext) <= self::MAC_BYTE_SIZE) { + if (self::ourStrlen($ciphertext) <= $config['MAC_BYTE_SIZE']) { throw new Ex\InvalidCiphertextException( "Ciphertext is too short." ); } - $hmac = self::ourSubstr($ciphertext, 0, self::MAC_BYTE_SIZE); - if ($hmac === FALSE) { + $hmac = self::ourSubstr( + $ciphertext, + 0, + $config['MAC_BYTE_SIZE'] + ); + if ($hmac === false) { throw new Ex\CannotPerformOperationException(); } - $ciphertext = self::ourSubstr($ciphertext, self::MAC_BYTE_SIZE); - if ($ciphertext === FALSE) { + $salt = self::ourSubstr( + $ciphertext, + $config['MAC_BYTE_SIZE'], + $config['SALT_SIZE'] + ); + if ($salt === false) { + throw new Ex\CannotPerformOperationException(); + } + + $ciphertext = self::ourSubstr( + $ciphertext, + $config['MAC_BYTE_SIZE'] + $config['SALT_SIZE'] + ); + if ($ciphertext === false) { throw new Ex\CannotPerformOperationException(); } // Regenerate the same authentication sub-key. - $akey = self::HKDF(self::HASH_FUNCTION, $key, self::KEY_BYTE_SIZE, self::AUTHENTICATION_INFO); + $akey = self::HKDF($config['HASH_FUNCTION'], $key, $config['KEY_BYTE_SIZE'], $config['AUTHENTICATION_INFO'], $salt, $config); + + if (self::verifyHMAC($hmac, $version . $salt . $ciphertext, $akey)) { + // Regenerate the same encryption sub-key. + $ekey = self::HKDF($config['HASH_FUNCTION'], $key, $config['KEY_BYTE_SIZE'], $config['ENCRYPTION_INFO'], $salt, $config); + + // Extract the initialization vector from the ciphertext. + self::EnsureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + if ($ivsize === false || $ivsize <= 0) { + throw new Ex\CannotPerformOperationException( + "Could not get the IV length from OpenSSL" + ); + } + if (self::ourStrlen($ciphertext) <= $ivsize) { + throw new Ex\InvalidCiphertextException( + "Ciphertext is too short." + ); + } + $iv = self::ourSubstr($ciphertext, 0, $ivsize); + if ($iv === false) { + throw new Ex\CannotPerformOperationException(); + } + $ciphertext = self::ourSubstr($ciphertext, $ivsize); + if ($ciphertext === false) { + throw new Ex\CannotPerformOperationException(); + } + + $plaintext = self::plainDecrypt($ciphertext, $ekey, $iv, $config); + + return $plaintext; + } else { + /* + * We throw an exception instead of returning false because we want + * a script that doesn't handle this condition to CRASH, instead + * of thinking the ciphertext decrypted to the value false. + */ + throw new Ex\InvalidCiphertextException( + "Integrity check failed." + ); + } + } + + /** + * Decrypts a ciphertext (legacy -- before version tagging) + * + * $ciphertext is the ciphertext to decrypt. + * $key is the key that the ciphertext was encrypted with. + * You MUST catch exceptions thrown by this function. See docs above. + * + * @param string $ciphertext + * @param string $key + * @return type + * @throws Ex\CannotPerformOperationException + * @throws Ex\InvalidCiphertextException + */ + public static function legacyDecrypt($ciphertext, $key) + { + self::runtimeTest(); + $config = self::getVersionConfig(self::LEGACY_VERSION); + + // Extract the HMAC from the front of the ciphertext. + if (self::ourStrlen($ciphertext) <= $config['MAC_BYTE_SIZE']) { + throw new Ex\InvalidCiphertextException( + "Ciphertext is too short." + ); + } + $hmac = self::ourSubstr($ciphertext, 0, $config['MAC_BYTE_SIZE']); + if ($hmac === false) { + throw new Ex\CannotPerformOperationException(); + } + $ciphertext = self::ourSubstr($ciphertext, $config['MAC_BYTE_SIZE']); + if ($ciphertext === false) { + throw new Ex\CannotPerformOperationException(); + } + + // Regenerate the same authentication sub-key. + $akey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['AUTHENTICATION_INFO'], + null, + $config + ); if (self::verifyHMAC($hmac, $ciphertext, $akey)) { // Regenerate the same encryption sub-key. - $keysize = self::KEY_BYTE_SIZE; - $ekey = self::HKDF(self::HASH_FUNCTION, $key, $keysize, self::ENCRYPTION_INFO); + $ekey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['ENCRYPTION_INFO'], + null, + $config + ); // Extract the initialization vector from the ciphertext. self::EnsureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length(self::CIPHER_METHOD); - if ($ivsize === FALSE || $ivsize <= 0) { + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + if ($ivsize === false || $ivsize <= 0) { throw new Ex\CannotPerformOperationException( "Could not get the IV length from OpenSSL" ); @@ -164,22 +286,22 @@ public static function decrypt($ciphertext, $key) ); } $iv = self::ourSubstr($ciphertext, 0, $ivsize); - if ($iv === FALSE) { + if ($iv === false) { throw new Ex\CannotPerformOperationException(); } $ciphertext = self::ourSubstr($ciphertext, $ivsize); - if ($ciphertext === FALSE) { + if ($ciphertext === false) { throw new Ex\CannotPerformOperationException(); } - $plaintext = self::plainDecrypt($ciphertext, $ekey, $iv); + $plaintext = self::plainDecrypt($ciphertext, $ekey, $iv, $config); return $plaintext; } else { /* - * We throw an exception instead of returning FALSE because we want + * We throw an exception instead of returning false because we want * a script that doesn't handle this condition to CRASH, instead - * of thinking the ciphertext decrypted to the value FALSE. + * of thinking the ciphertext decrypted to the value false. */ throw new Ex\InvalidCiphertextException( "Integrity check failed." @@ -201,6 +323,8 @@ public static function runtimeTest() // 3: Tests have failed. static $test_state = 0; + $config = self::getVersionConfig(parent::VERSION); + if ($test_state === 1 || $test_state === 2) { return; } @@ -219,20 +343,20 @@ public static function runtimeTest() $test_state = 2; self::ensureFunctionExists('openssl_get_cipher_methods'); - if (\in_array(self::CIPHER_METHOD, \openssl_get_cipher_methods()) === FALSE) { + if (\in_array($config['CIPHER_METHOD'], \openssl_get_cipher_methods()) === false) { throw new Ex\CryptoTestFailedException("Cipher method not supported."); } - self::AESTestVector(); - self::HMACTestVector(); - self::HKDFTestVector(); + self::AESTestVector($config); + self::HMACTestVector($config); + self::HKDFTestVector($config); - self::testEncryptDecrypt(); - if (self::ourStrlen(self::createNewRandomKey()) != self::KEY_BYTE_SIZE) { + self::testEncryptDecrypt($config); + if (self::ourStrlen(self::createNewRandomKey()) != $config['KEY_BYTE_SIZE']) { throw new Ex\CryptoTestFailedException(); } - if (self::ENCRYPTION_INFO == self::AUTHENTICATION_INFO) { + if ($config['ENCRYPTION_INFO'] == $config['AUTHENTICATION_INFO']) { throw new Ex\CryptoTestFailedException(); } } catch (Ex\CryptoTestFailedException $ex) { @@ -247,22 +371,23 @@ public static function runtimeTest() /** * Never call this method directly! - * + * * Unauthenticated message encryption. - * + * * @param string $plaintext * @param string $key * @param string $iv + * @param array $config * @return string * @throws Ex\CannotPerformOperationException */ - private static function plainEncrypt($plaintext, $key, $iv) + private static function plainEncrypt($plaintext, $key, $iv, $config) { self::ensureConstantExists("OPENSSL_RAW_DATA"); self::ensureFunctionExists("openssl_encrypt"); $ciphertext = \openssl_encrypt( $plaintext, - self::CIPHER_METHOD, + $config['CIPHER_METHOD'], $key, OPENSSL_RAW_DATA, $iv @@ -279,27 +404,27 @@ private static function plainEncrypt($plaintext, $key, $iv) /** * Never call this method directly! - * + * * Unauthenticated message deryption. - * + * * @param string $ciphertext * @param string $key * @param string $iv * @return string * @throws Ex\CannotPerformOperationException */ - private static function plainDecrypt($ciphertext, $key, $iv) + private static function plainDecrypt($ciphertext, $key, $iv, $config) { self::ensureConstantExists("OPENSSL_RAW_DATA"); self::ensureFunctionExists("openssl_decrypt"); $plaintext = \openssl_decrypt( $ciphertext, - self::CIPHER_METHOD, + $config['CIPHER_METHOD'], $key, OPENSSL_RAW_DATA, $iv ); - if ($plaintext === FALSE) { + if ($plaintext === false) { throw new Ex\CannotPerformOperationException( "openssl_decrypt() failed." ); @@ -308,97 +433,9 @@ private static function plainDecrypt($ciphertext, $key, $iv) return $plaintext; } - /** - * Returns a random binary string of length $octets bytes. - * - * @param int $octets - * @return string (raw binary) - * @throws Ex\CannotPerformOperationException - */ - private static function secureRandom($octets) - { - self::ensureFunctionExists('openssl_random_pseudo_bytes'); - $secure = false; - $random = \openssl_random_pseudo_bytes($octets, $secure); - if ($random === FALSE || $secure === FALSE) { - throw new Ex\CannotPerformOperationException( - "openssl_random_pseudo_bytes() failed." - ); - } - return $random; - } - - /** - * Use HKDF to derive multiple keys from one. - * http://tools.ietf.org/html/rfc5869 - * - * @param string $hash Hash Function - * @param string $ikm Initial Keying Material - * @param int $length How many bytes? - * @param string $info What sort of key are we deriving? - * @param string $salt - * @return string - * @throws Ex\CannotPerformOperationException - */ - private static function HKDF($hash, $ikm, $length, $info = '', $salt = null) - { - // Find the correct digest length as quickly as we can. - $digest_length = self::MAC_BYTE_SIZE; - if ($hash != self::HASH_FUNCTION) { - $digest_length = self::ourStrlen(\hash_hmac($hash, '', '', true)); - } - - // Sanity-check the desired output length. - if (empty($length) || !\is_int($length) || - $length < 0 || $length > 255 * $digest_length) { - throw new Ex\CannotPerformOperationException( - "Bad output length requested of HKDF." - ); - } - - // "if [salt] not provided, is set to a string of HashLen zeroes." - if (\is_null($salt)) { - $salt = \str_repeat("\x00", $digest_length); - } - - // HKDF-Extract: - // PRK = HMAC-Hash(salt, IKM) - // The salt is the HMAC key. - $prk = \hash_hmac($hash, $ikm, $salt, true); - - // HKDF-Expand: - - // This check is useless, but it serves as a reminder to the spec. - if (self::ourStrlen($prk) < $digest_length) { - throw new Ex\CannotPerformOperationException(); - } - - // T(0) = '' - $t = ''; - $last_block = ''; - for ($block_index = 1; self::ourStrlen($t) < $length; ++$block_index) { - // T(i) = HMAC-Hash(PRK, T(i-1) | info | 0x??) - $last_block = \hash_hmac( - $hash, - $last_block . $info . \chr($block_index), - $prk, - true - ); - // T = T(1) | T(2) | T(3) | ... | T(N) - $t .= $last_block; - } - - // ORM = first L octets of T - $orm = self::ourSubstr($t, 0, $length); - if ($orm === FALSE) { - throw new Ex\CannotPerformOperationException(); - } - return $orm; - } - /** * Verify a HMAC without crypto side-channels - * + * * @staticvar boolean $native Use native hash_equals()? * @param string $correct_hmac HMAC string (raw binary) * @param string $message Ciphertext (raw binary) @@ -408,107 +445,91 @@ private static function HKDF($hash, $ikm, $length, $info = '', $salt = null) */ private static function verifyHMAC($correct_hmac, $message, $key) { - static $native = null; - $message_hmac = \hash_hmac(self::HASH_FUNCTION, $message, $key, true); - - if ($native === null) { - $native = \function_exists('hash_equals'); - } - if ($native) { - return \hash_equals($correct_hmac, $message_hmac); + static $config = null; + if ($config === null) { + $config = self::getVersionConfig(parent::VERSION); } - - // We can't just compare the strings with '==', since it would make - // timing attacks possible. We could use the XOR-OR constant-time - // comparison algorithm, but I'm not sure if that's good enough way up - // here in an interpreted language. So we use the method of HMACing the - // strings we want to compare with a random key, then comparing those. - - // NOTE: This leaks information when the strings are not the same - // length, but they should always be the same length here. Enforce it: - if (self::ourStrlen($correct_hmac) !== self::ourStrlen($message_hmac)) { - throw new Ex\CannotPerformOperationException( - "Computed and included HMACs are not the same length." - ); - } - - $blind = self::createNewRandomKey(); - $message_compare = \hash_hmac(self::HASH_FUNCTION, $message_hmac, $blind); - $correct_compare = \hash_hmac(self::HASH_FUNCTION, $correct_hmac, $blind); - return $correct_compare === $message_compare; + $message_hmac = \hash_hmac($config['HASH_FUNCTION'], $message, $key, true); + return self::hashEquals($correct_hmac, $message_hmac); } - private static function testEncryptDecrypt() + private static function testEncryptDecrypt($config) { $key = self::createNewRandomKey(); $data = "EnCrYpT EvErYThInG\x00\x00"; + if (empty($config)) { + $config = self::getVersionConfig(parent::VERSION); + } // Make sure encrypting then decrypting doesn't change the message. - $ciphertext = self::encrypt($data, $key); + $ciphertext = self::encrypt($data, $key, true); try { - $decrypted = self::decrypt($ciphertext, $key); + $decrypted = self::decrypt($ciphertext, $key, true); } catch (Ex\InvalidCiphertextException $ex) { // It's important to catch this and change it into a // CryptoTestFailedExceptionException, otherwise a test failure could trick // the user into thinking it's just an invalid ciphertext! throw new Ex\CryptoTestFailedException(); } - if($decrypted !== $data) { + if ($decrypted !== $data) { throw new Ex\CryptoTestFailedException(); } // Modifying the ciphertext: Appending a string. try { - self::decrypt($ciphertext . "a", $key); + self::decrypt($ciphertext . "a", $key, true); throw new Ex\CryptoTestFailedException(); } catch (Ex\InvalidCiphertextException $e) { /* expected */ } // Modifying the ciphertext: Changing an IV byte. try { - $ciphertext[0] = chr((ord($ciphertext[0]) + 1) % 256); - self::decrypt($ciphertext, $key); + $ciphertext[4] = chr((ord($ciphertext[4]) + 1) % 256); + self::decrypt($ciphertext, $key, true); throw new Ex\CryptoTestFailedException(); } catch (Ex\InvalidCiphertextException $e) { /* expected */ } // Decrypting with the wrong key. $key = self::createNewRandomKey(); $data = "abcdef"; - $ciphertext = self::encrypt($data, $key); + $ciphertext = self::encrypt($data, $key, true); $wrong_key = self::createNewRandomKey(); try { - self::decrypt($ciphertext, $wrong_key); + self::decrypt($ciphertext, $wrong_key, true); throw new Ex\CryptoTestFailedException(); } catch (Ex\InvalidCiphertextException $e) { /* expected */ } // Ciphertext too small (shorter than HMAC). $key = self::createNewRandomKey(); - $ciphertext = \str_repeat("A", self::MAC_BYTE_SIZE - 1); + $ciphertext = \str_repeat("A", $config['MAC_BYTE_SIZE'] - 1); try { - self::decrypt($ciphertext, $key); + self::decrypt($ciphertext, $key, true); throw new Ex\CryptoTestFailedException(); } catch (Ex\InvalidCiphertextException $e) { /* expected */ } } /** * Run-time testing - * + * * @throws Ex\CryptoTestFailedException */ - private static function HKDFTestVector() + private static function HKDFTestVector($config) { // HKDF test vectors from RFC 5869 + if (empty($config)) { + $config = self::getVersionConfig(parent::VERSION); + } // Test Case 1 $ikm = \str_repeat("\x0b", 22); - $salt = self::hexToBytes("000102030405060708090a0b0c"); - $info = self::hexToBytes("f0f1f2f3f4f5f6f7f8f9"); + $salt = self::hexToBin("000102030405060708090a0b0c"); + $info = self::hexToBin("f0f1f2f3f4f5f6f7f8f9"); $length = 42; - $okm = self::hexToBytes( + $okm = self::hexToBin( "3cb25f25faacd57a90434f64d0362f2a" . "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" . "34007208d5b887185865" ); - $computed_okm = self::HKDF("sha256", $ikm, $length, $info, $salt); + $computed_okm = self::HKDF("sha256", $ikm, $length, $info, $salt, $config); if ($computed_okm !== $okm) { throw new Ex\CryptoTestFailedException(); } @@ -516,12 +537,12 @@ private static function HKDFTestVector() // Test Case 7 $ikm = \str_repeat("\x0c", 22); $length = 42; - $okm = self::hexToBytes( + $okm = self::hexToBin( "2c91117204d745f3500d636a62f64f0a" . "b3bae548aa53d423b0d1f27ebba6f5e5" . "673a081d70cce7acfc48" ); - $computed_okm = self::HKDF("sha1", $ikm, $length); + $computed_okm = self::HKDF("sha1", $ikm, $length, '', null, $config); if ($computed_okm !== $okm) { throw new Ex\CryptoTestFailedException(); } @@ -530,218 +551,96 @@ private static function HKDFTestVector() /** * Run-Time tests - * + * * @throws Ex\CryptoTestFailedException */ - private static function HMACTestVector() + private static function HMACTestVector($config) { + if (empty($config)) { + $config = self::getVersionConfig(parent::VERSION); + } // HMAC test vector From RFC 4231 (Test Case 1) $key = \str_repeat("\x0b", 20); $data = "Hi There"; $correct = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; - if (\hash_hmac(self::HASH_FUNCTION, $data, $key) !== $correct) { + if (\hash_hmac($config['HASH_FUNCTION'], $data, $key) !== $correct) { throw new Ex\CryptoTestFailedException(); } } /** * Run-time tests - * + * * @throws Ex\CryptoTestFailedException */ - private static function AESTestVector() + private static function AESTestVector($config) { - // AES CBC mode test vector from NIST SP 800-38A - $key = self::hexToBytes("2b7e151628aed2a6abf7158809cf4f3c"); - $iv = self::hexToBytes("000102030405060708090a0b0c0d0e0f"); - $plaintext = self::hexToBytes( + // AES CTR mode test vector from NIST SP 800-38A + $key = self::hexToBin("2b7e151628aed2a6abf7158809cf4f3c"); + $iv = self::hexToBin("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + $plaintext = self::hexToBin( "6bc1bee22e409f96e93d7e117393172a" . "ae2d8a571e03ac9c9eb76fac45af8e51" . "30c81c46a35ce411e5fbc1191a0a52ef" . "f69f2445df4f9b17ad2b417be66c3710" ); - $ciphertext = self::hexToBytes( - "7649abac8119b246cee98e9b12e9197d" . - "5086cb9b507219ee95db113a917678b2" . - "73bed6b8e3c1743b7116e69e22229516" . - "3ff1caa1681fac09120eca307586e1a7" . - /* Block due to padding. Not from NIST test vector. - Padding Block: 10101010101010101010101010101010 - Ciphertext: 3ff1caa1681fac09120eca307586e1a7 - (+) 2fe1dab1780fbc19021eda206596f1b7 - AES 8cb82807230e1321d3fae00d18cc2012 - - */ - "8cb82807230e1321d3fae00d18cc2012" + $ciphertext = self::hexToBin( + "874d6191b620e3261bef6864990db6ce" . + "9806f66b7970fdff8617187bb9fffdff" . + "5ae4df3edbd5d35e5b4f09020db03eab" . + "1e031dda2fbe03d1792170a0f3009cee" ); - $computed_ciphertext = self::plainEncrypt($plaintext, $key, $iv); + $config = self::getVersionConfig(parent::VERSION); + + $computed_ciphertext = self::plainEncrypt($plaintext, $key, $iv, $config); if ($computed_ciphertext !== $ciphertext) { throw new Ex\CryptoTestFailedException(); } - $computed_plaintext = self::plainDecrypt($ciphertext, $key, $iv); + $computed_plaintext = self::plainDecrypt($ciphertext, $key, $iv, $config); if ($computed_plaintext !== $plaintext) { throw new Ex\CryptoTestFailedException(); } } - /* WARNING: Do not call this function on secrets. It creates side channels. */ - private static function hexToBytes($hex_string) - { - return \pack("H*", $hex_string); - } - - - /** - * If the constant doesn't exist, throw an exception - * - * @param string $name - * @throws Ex\CannotPerformOperationException - */ - private static function ensureConstantExists($name) - { - if (!\defined($name)) { - throw new Ex\CannotPerformOperationException(); - } - } - - /** - * If the functon doesn't exist, throw an exception - * - * @param string $name Function name - * @throws Ex\CannotPerformOperationException - */ - private static function ensureFunctionExists($name) - { - if (!\function_exists($name)) { - throw new Ex\CannotPerformOperationException(); - } - } - - /* - * We need these strlen() and substr() functions because when - * 'mbstring.func_overload' is set in php.ini, the standard strlen() and - * substr() are replaced by mb_strlen() and mb_substr(). - */ - - /** - * Safe string length - * - * @staticvar boolean $exists - * @param string $str - * @return int - */ - private static function ourStrlen($str) - { - static $exists = null; - if ($exists === null) { - $exists = \function_exists('mb_strlen'); - } - if ($exists) { - $length = \mb_strlen($str, '8bit'); - if ($length === FALSE) { - throw new Ex\CannotPerformOperationException( - "mb_strlen() failed." - ); - } - return $length; - } else { - return \strlen($str); - } - } - /** - * Safe substring - * - * @staticvar boolean $exists - * @param string $str - * @param int $start - * @param int $length - * @return string + * Take a 4-byte header and get meaningful version information out of it. + * Common configuration options should go in Core.php + * + * @param string $header */ - private static function ourSubstr($str, $start, $length = null) + protected static function getVersionConfig($header) { - static $exists = null; - if ($exists === null) { - $exists = \function_exists('mb_substr'); - } - if ($exists) - { - // mb_substr($str, 0, NULL, '8bit') returns an empty string on PHP - // 5.3, so we have to find the length ourselves. - if (!isset($length)) { - if ($start >= 0) { - $length = self::ourStrlen($str) - $start; - } else { - $length = -$start; - } + $valid = 0; + $valid |= $header[0] ^ "\xDE"; + $valid |= $header[1] ^ "\xF5"; + $major = \ord($header[2]); + $minor = \ord($header[3]); + + if ($major === 1) { + return [ + 'CIPHER_METHOD' => 'aes-128-cbc', + 'KEY_BYTE_SIZE' => 16, + 'HASH_FUNCTION' => 'sha256', + 'MAC_BYTE_SIZE' => 32, + 'ENCRYPTION_INFO' => 'DefusePHP|KeyForEncryption', + 'AUTHENTICATION_INFO' => 'DefusePHP|KeyForAuthentication' + ]; + } + $config = parent::getCoreVersionConfig($major, $minor, $valid); + + if ($major === 2) { + switch ($minor) { + case 0: + $config['CIPHER_METHOD'] = 'aes-128-ctr'; + break; } - - return \mb_substr($str, $start, $length, '8bit'); } - // Unlike mb_substr(), substr() doesn't accept NULL for length - if (isset($length)) { - return \substr($str, $start, $length); - } else { - return \substr($str, $start); - } - } - /** - * Convert a binary string into a hexadecimal string without cache-timing - * leaks - * - * @param string $bin_string (raw binary) - * @return string - */ - public static function binToHex($bin_string) - { - $hex = ''; - $len = self::ourStrlen($bin_string); - for ($i = 0; $i < $len; ++$i) { - $c = \ord($bin_string[$i]) & 0xf; - $b = \ord($bin_string[$i]) >> 4; - $hex .= \chr(87 + $b + ((($b - 10) >> 8) & ~38)); - $hex .= \chr(87 + $c + ((($c - 10) >> 8) & ~38)); - } - return $hex; - } - - /** - * Convert a hexadecimal string into a binary string without cache-timing - * leaks - * - * @param string $hex_string - * @return string (raw binary) - */ - public static function hexToBin($hex_string) - { - $hex_pos = 0; - $bin = ''; - $hex_len = self::ourStrlen($hex_string); - $state = 0; - - while ($hex_pos < $hex_len) { - $c = \ord($hex_string[$hex_pos]); - $c_num = $c ^ 48; - $c_num0 = ($c_num - 10) >> 8; - $c_alpha = ($c & ~32) - 55; - $c_alpha0 = (($c_alpha - 10) ^ ($c_alpha - 16)) >> 8; - if (($c_num0 | $c_alpha0) === 0) { - throw new \RangeException( - 'Crypto::hexToBin() only expects hexadecimal characters' - ); - } - $c_val = ($c_num0 & $c_num) | ($c_alpha & $c_alpha0); - if ($state === 0) { - $c_acc = $c_val * 16; - } else { - $bin .= \chr($c_acc | $c_val); - } - $state = $state ? 0 : 1; - ++$hex_pos; + if ($valid !== 0) { + throw new Ex\InvalidCiphertextException('Unknown ciphertext version'); } - return $bin; + return $config; } } diff --git a/src/Exception/CannotPerformOperationException.php b/src/Exception/CannotPerformOperationException.php index e909e09..8cf9403 100644 --- a/src/Exception/CannotPerformOperationException.php +++ b/src/Exception/CannotPerformOperationException.php @@ -1,7 +1,7 @@ + * All rights reserved. + * + * Streaming File Encryption Class + * Copyright (c) 2015 Paragon Initiative Enterprises + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +final class File extends Core implements StreamInterface +{ + /** + * Use this to generate a random encryption key. + * + * @return string + */ + public static function createNewRandomKey() + { + $config = self::getVersionConfig(self::VERSION); + return self::secureRandom($config['KEY_BYTE_SIZE']); + } + + /** + * Encrypt the contents at $inputFilename, storing the result in + * $outputFilename using HKDF of $key to perform authenticated encryption + * + * @param string $inputFilename + * @param string $outputFilename + * @param string $key + * @return boolean + */ + public static function encryptFile($inputFilename, $outputFilename, $key) + { + if (!\is_string($inputFilename)) { + throw new Ex\InvalidInput( + 'Input filename must be a string!' + ); + } + if (!\is_string($outputFilename)) { + throw new Ex\InvalidInput( + 'Output filename must be a string!' + ); + } + + /** Open the file handles **/ + + /** + * Input file handle + */ + $if = \fopen($inputFilename, 'rb'); + if ($if === false) { + throw new Ex\CannotPerformOperation( + 'Cannot open input file for encrypting' + ); + } + \stream_set_read_buffer($if, 0); + + /** + * Output file handle + */ + $of = \fopen($outputFilename, 'wb'); + if ($of === false) { + throw new Ex\CannotPerformOperation( + 'Cannot open output file for encrypting' + ); + } + \stream_set_write_buffer($of, 0); + + /** + * Use encryptResource() to actually write the encrypted data to $of + */ + $encrypted = self::encryptResource($if, $of, $key); + + /** + * Close handles + */ + if (\fclose($if) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot close input file for encrypting' + ); + } + if (\fclose($of) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot close input file for encrypting' + ); + } + + /** + * Return the result (which should be true) + */ + return $encrypted; + } + + /** + * Decrypt the contents at $inputFilename, storing the result in $outputFilename + * using HKDF of $key to decrypt then verify + * + * @param string $inputFilename + * @param string $outputFilename + * @param string $key + * @return boolean + */ + public static function decryptFile($inputFilename, $outputFilename, $key) + { + if (!\is_string($inputFilename)) { + throw new Ex\InvalidInput( + 'Input filename must be a string!' + ); + } + if (!\is_string($outputFilename)) { + throw new Ex\InvalidInput( + 'Output filename must be a string!' + ); + } + + /** Open the file handles **/ + + /** + * Input file handle + */ + $if = \fopen($inputFilename, 'rb'); + if ($if === false) { + throw new Ex\CannotPerformOperation( + 'Cannot open input file for decrypting' + ); + } + \stream_set_read_buffer($if, 0); + + /** + * Output file handle + */ + $of = \fopen($outputFilename, 'wb'); + if ($of === false) { + throw new Ex\CannotPerformOperation( + 'Cannot open output file for decrypting' + ); + } + \stream_set_write_buffer($of, 0); + + /** + * Use decryptResource() to actually write the decrypted data to $of + */ + $decrypted = self::decryptResource($if, $of, $key); + + /** + * Close handles + */ + if (\fclose($if) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot close input file for decrypting' + ); + } + if (\fclose($of) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot close input file for decrypting' + ); + } + + /** + * Return the result (which should be true) + */ + return $decrypted; + } + + /** + * Encrypt the contents of a file handle $inputHandle and store the results + * in $outputHandle using HKDF of $key to perform authenticated encryption + * + * @param resource $inputHandle + * @param resource $outputHandle + * @param string $key + * @return boolean + */ + public static function encryptResource($inputHandle, $outputHandle, $key) + { + // Because we don't have strict typing in PHP 5 + if (!\is_resource($inputHandle)) { + throw new Ex\InvalidInput( + 'Input handle must be a resource!' + ); + } + if (!\is_resource($outputHandle)) { + throw new Ex\InvalidInput( + 'Output handle must be a resource!' + ); + } + $config = self::getVersionConfig(parent::VERSION); + + // Let's add this check before anything + if (!\in_array($config['HASH_FUNCTION'], \hash_algos())) { + throw new Ex\CannotPerformOperation( + 'The specified hash function does not exist' + ); + } + + // Sanity check; key must be the appropriate length! + if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { + throw new Ex\InvalidInput( + 'Invalid key length. Keys should be '.$config['KEY_BYTE_SIZE'].' bytes long.' + ); + } + + /** + * Let's split our keys + */ + $file_salt = self::secureRandom($config['SALT_SIZE']); + + // $ekey -- Encryption Key -- used for AES + $ekey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['ENCRYPTION_INFO'], + $file_salt, + $config + ); + + // $akey -- Authentication Key -- used for HMAC + $akey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['AUTHENTICATION_INFO'], + $file_salt, + $config + ); + + /** + * Generate a random initialization vector. + */ + self::ensureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + if ($ivsize === false || $ivsize <= 0) { + throw new Ex\CannotPerformOperation( + 'Improper IV size' + ); + } + $iv = self::secureRandom($ivsize); + + /** + * First let's write our header, file salt, and IV to the first N blocks of the output file + */ + if (\fwrite( + $outputHandle, + parent::VERSION . $file_salt . $iv, + parent::HEADER_VERSION_SIZE + $config['SALT_SIZE'] + $ivsize + ) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot write to output file' + ); + } + + /** + * We're going to initialize a HMAC-SHA256 with the given $akey + * and update it with each ciphertext chunk + */ + $hmac = \hash_init($config['HASH_FUNCTION'], HASH_HMAC, $akey); + if ($hmac === false) { + throw new Ex\CannotPerformOperation( + 'Cannot initialize a hash context' + ); + } + + /** + * We operate on $thisIv using a hash-based PRF derived from the initial + * IV for the first block + */ + $thisIv = $iv; + + /** + * How much do we increase the counter after each buffered encryption to + * prevent nonce reuse? + */ + $inc = $config['BUFFER'] / $config['BLOCK_SIZE']; + + /** + * Let's MAC our salt and IV/nonce + */ + \hash_update($hmac, parent::VERSION); + \hash_update($hmac, $file_salt); + \hash_update($hmac, $iv); + + /** + * Iterate until we reach the end of the input file + */ + while (!\feof($inputHandle)) { + $read = \fread($inputHandle, $config['BUFFER']); + if ($read === false) { + throw new Ex\CannotPerformOperation( + 'Cannot read input file' + ); + } + $thisIv = self::incrementCounter($thisIv, $inc, $config); + + /** + * Perform the AES encryption. Encrypts the plaintext. + */ + $encrypted = \openssl_encrypt( + $read, + $config['CIPHER_METHOD'], + $ekey, + OPENSSL_RAW_DATA, + $thisIv + ); + /** + * Check that the encryption was performed successfully + */ + if ($encrypted === false) { + throw new Ex\CannotPerformOperation( + 'OpenSSL encryption error' + ); + } + + /** + * Write the ciphertext to the output file + */ + if (\fwrite($outputHandle, $encrypted, self::ourStrlen($encrypted)) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot write to output file during encryption' + ); + } + + /** + * Update the HMAC for the entire file with the data from this block + */ + \hash_update($hmac, $encrypted); + } + + // Now let's get our HMAC and append it + $finalHMAC = \hash_final($hmac, true); + + $appended = \fwrite($outputHandle, $finalHMAC, $config['MAC_BYTE_SIZE']); + if ($appended === false) { + throw new Ex\CannotPerformOperation( + 'Cannot write to output file' + ); + } + return true; + } + + /** + * Decrypt the contents of a file handle $inputHandle and store the results + * in $outputHandle using HKDF of $key to decrypt then verify + * + * @param resource $inputHandle + * @param resource $outputHandle + * @param string $key + * @return boolean + */ + public static function decryptResource($inputHandle, $outputHandle, $key) + { + // Because we don't have strict typing in PHP 5 + if (!\is_resource($inputHandle)) { + throw new Ex\InvalidInput( + 'Input handle must be a resource!' + ); + } + if (!\is_resource($outputHandle)) { + throw new Ex\InvalidInput( + 'Output handle must be a resource!' + ); + } + + // Parse the header, ensuring we get 4 bytes + $header = ''; + $remaining = 4; + do { + $header .= \fread($inputHandle, $remaining); + $remaining = 4 - self::ourStrlen($header); + } while ($remaining > 0); + + $config = self::getVersionConfig($header); + + // Let's add this check before anything + if (!\in_array($config['HASH_FUNCTION'], \hash_algos())) { + throw new Ex\CannotPerformOperation( + 'The specified hash function does not exist' + ); + } + + // Sanity check; key must be the appropriate length! + if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { + throw new Ex\InvalidInput( + 'Invalid key length. Keys should be '.$config['KEY_BYTE_SIZE'].' bytes long.' + ); + } + // Let's grab the file salt. + $file_salt = \fread($inputHandle, $config['SALT_SIZE']); + if ($file_salt === false ) { + throw new Ex\CannotPerformOperation( + 'Cannot read input file' + ); + } + + // For storing MACs of each buffer chunk + $macs = []; + + /** + * 1. We need to decode some values from our files + */ + /** + * Let's split our keys + * + * $ekey -- Encryption Key -- used for AES + */ + $ekey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['ENCRYPTION_INFO'], + $file_salt, + $config + ); + + /** + * $akey -- Authentication Key -- used for HMAC + */ + $akey = self::HKDF( + $config['HASH_FUNCTION'], + $key, + $config['KEY_BYTE_SIZE'], + $config['AUTHENTICATION_INFO'], + $file_salt, + $config + ); + + /** + * Grab our IV from the encrypted message + * + * It should be the first N blocks of the file (N = 16) + */ + $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + $iv = \fread($inputHandle, $ivsize); + if ($iv === false ) { + throw new Ex\CannotPerformOperation( + 'Cannot read input file' + ); + } + + // How much do we increase the counter after each buffered encryption to prevent nonce reuse + $inc = $config['BUFFER'] / $config['BLOCK_SIZE']; + + $thisIv = $iv; + + /** + * Let's grab our MAC + * + * It should be the last N blocks of the file (N = 32) + */ + if (\fseek($inputHandle, (-1 * $config['MAC_BYTE_SIZE']), SEEK_END) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot seek to beginning of MAC within input file' + ); + } + + // Grab our last position of ciphertext before we read the MAC + $cipher_end = \ftell($inputHandle); + if ($cipher_end === false) { + throw new Ex\CannotPerformOperation( + 'Cannot read input file' + ); + } + --$cipher_end; // We need to subtract one + + // We keep our MAC stored in this variable + $stored_mac = \fread($inputHandle, $config['MAC_BYTE_SIZE']); + if ($stored_mac === false) { + throw new Ex\CannotPerformOperation( + 'Cannot read input file' + ); + } + + /** + * We begin recalculating the HMAC for the entire file... + */ + $hmac = \hash_init($config['HASH_FUNCTION'], HASH_HMAC, $akey); + if ($hmac === false) { + throw new Ex\CannotPerformOperation( + 'Cannot initialize a hash context' + ); + } + + /** + * Reset file pointer to the beginning of the file after the header + */ + if (\fseek($inputHandle, parent::HEADER_VERSION_SIZE, SEEK_SET) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot read seek within input file' + ); + } + + /** + * Set it to the first non-salt and non-IV byte + */ + if (\fseek($inputHandle, $config['SALT_SIZE'] + $ivsize, SEEK_CUR) === false) { + throw new Ex\CannotPerformOperation( + 'Cannot read seek input file to beginning of ciphertext' + ); + } + /** + * 2. Let's recalculate the MAC + */ + /** + * Let's initialize our $hmac hasher with our Salt and IV + */ + \hash_update($hmac, $header); + \hash_update($hmac, $file_salt); + \hash_update($hmac, $iv); + $hmac2 = \hash_copy($hmac); + + $break = false; + while (!$break) { + /** + * First, grab the current position + */ + $pos = \ftell($inputHandle); + if ($pos === false) { + throw new Ex\CannotPerformOperation( + 'Could not get current position in input file during decryption' + ); + } + + /** + * Would a full DBUFFER read put it past the end of the + * ciphertext? If so, only return a portion of the file. + */ + if ($pos + $config['BUFFER'] >= $cipher_end) { + $break = true; + $read = \fread($inputHandle, $cipher_end - $pos + 1); + } else { + $read = \fread($inputHandle, $config['BUFFER']); + } + if ($read === false) { + throw new Ex\CannotPerformOperation( + 'Could not read input file during decryption' + ); + } + /** + * We're updating our HMAC and nothing else + */ + \hash_update($hmac, $read); + + /** + * Store a MAC of each chunk + */ + $chunkMAC = \hash_copy($hmac); + if ($chunkMAC === false) { + throw new Ex\CannotPerformOperation( + 'Cannot duplicate a hash context' + ); + } + $macs []= \hash_final($chunkMAC); + } + /** + * We should now have enough data to generate an identical HMAC + */ + $finalHMAC = \hash_final($hmac, true); + /** + * 3. Did we match? + */ + if (!self::hashEquals($finalHMAC, $stored_mac)) { + throw new Ex\InvalidCiphertext( + 'Message Authentication failure; tampering detected.' + ); + } + /** + * 4. Okay, let's begin decrypting + */ + /** + * Return file pointer to the first non-header, non-IV byte in the file + */ + if (\fseek($inputHandle, $config['SALT_SIZE'] + $ivsize + self::HEADER_VERSION_SIZE, SEEK_SET) === false) { + throw new Ex\CannotPerformOperation( + 'Could not move the input file pointer during decryption' + ); + } + + /** + * Should we break the writing? + */ + $breakW = false; + + /** + * This loop writes plaintext to the destination file: + */ + $result = null; + while (!$breakW) { + /** + * Get the current position + */ + $pos = \ftell($inputHandle); + if ($pos === false) { + throw new Ex\CannotPerformOperation( + 'Could not get current position in input file during decryption' + ); + } + + /** + * Would a full BUFFER read put it past the end of the + * ciphertext? If so, only return a portion of the file. + */ + if ($pos + $config['BUFFER'] >= $cipher_end) { + $breakW = true; + $read = \fread($inputHandle, $cipher_end - $pos + 1); + } else { + $read = \fread($inputHandle, $config['BUFFER']); + } + if ($read === false) { + throw new Ex\CannotPerformOperation( + 'Could not read input file during decryption' + ); + } + + /** + * Recalculate the MAC, compare with the one stored in the $macs + * array to ensure attackers couldn't tamper with the file + * after MAC verification + */ + \hash_update($hmac2, $read); + $calcMAC = \hash_copy($hmac2); + if ($calcMAC === false) { + throw new Ex\CannotPerformOperation( + 'Cannot duplicate a hash context' + ); + } + $calc = \hash_final($calcMAC); + + if (empty($macs)) { + throw new Ex\InvalidCiphertext( + 'File was modified after MAC verification' + ); + } elseif (!self::hashEquals(\array_shift($macs), $calc)) { + throw new Ex\InvalidCiphertextException( + 'File was modified after MAC verification' + ); + } + + $thisIv = self::incrementCounter($thisIv, $inc, $config); + + /** + * Perform the AES decryption. Decrypts the message. + */ + $decrypted = \openssl_decrypt( + $read, + $config['CIPHER_METHOD'], + $ekey, + OPENSSL_RAW_DATA, + $thisIv + ); + + /** + * Test for decryption faulure + */ + if ($decrypted === false) { + throw new Ex\CannotPerformOperation( + 'OpenSSL decryption error' + ); + } + + /** + * Write the plaintext out to the output file + */ + $result = \fwrite( + $outputHandle, + $decrypted, + self::ourStrlen($decrypted) + ); + + /** + * Check result + */ + if ($result === false) { + throw new Ex\CannotPerformOperation( + 'Could not write to output file during decryption.' + ); + } + } + // This should be an integer + return $result; + } + + /** + * Take a 4-byte header and get meaningful version information out of it + * + * @param string $header + */ + protected static function getVersionConfig($header) + { + $valid = 0; + $valid |= $header[0] ^ "\xDE"; + $valid |= $header[1] ^ "\xF5"; + $major = \ord($header[2]); + $minor = \ord($header[3]); + $config = parent::getCoreVersionConfig($major, $minor, $valid); + + if ($major === 2) { + switch ($minor) { + case 0: + $config['CIPHER_METHOD'] = 'aes-128-ctr'; + $config['BUFFER'] = 1048576; + break; + default: + $valid |= 0xFF; + break; + } + } else { + $valid |= 0xFF; + } + + if ($valid !== 0) { + throw new Ex\InvalidCiphertextException('Unknown ciphertext version'); + } + return $config; + } +} diff --git a/src/StreamInterface.php b/src/StreamInterface.php new file mode 100644 index 0000000..e31a5e9 --- /dev/null +++ b/src/StreamInterface.php @@ -0,0 +1,49 @@ + 16, + 'KEY_BYTE_SIZE' => 16, + 'HASH_FUNCTION' => 'sha256', + 'MAC_BYTE_SIZE' => 32, + 'ENCRYPTION_INFO' => 'DefusePHP|KeyForEncryption', + 'AUTHENTICATION_INFO' => 'DefusePHP|KeyForAuthentication', + 'CIPHER_METHOD' => 'aes-128-ctr', + 'BUFFER' => 1048576 +]; + +$ctr = [ + str_repeat("\0", \openssl_cipher_iv_length('aes-128-ctr')), + str_repeat("\0", \openssl_cipher_iv_length('aes-128-ctr') - 2) . "\x00\x40" +]; +$test = \Defuse\Crypto\Core::incrementCounter($ctr[0], 64, $config); +if ($test !== $ctr[1]) { + echo "Counter mode malfunction\n"; + exit(255); +} +$a = str_repeat('a', 2048); + +/** + * Let's verify that our counter is behaving properly + */ +$cipher = openssl_encrypt($a, $config['CIPHER_METHOD'], 'YELLOW SUBMARINE', OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $ctr[0]); +$slice = mb_substr($cipher, 1024, 1024, '8bit'); +$decrypt = openssl_decrypt($slice, $config['CIPHER_METHOD'], 'YELLOW SUBMARINE', OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $test); + +if (!preg_match('/^a{1024}$/', $decrypt)) { + echo "Counter mode calculation error\n"; + exit(255); +} + +/** + * We should carry + */ +$start = str_repeat("\xFF", \openssl_cipher_iv_length('aes-128-ctr')); +$end = \Defuse\Crypto\Core::incrementCounter($start, 1, $config); +if ($end !== $ctr[0]) { + echo "Carry error\n"; + exit(255); +} \ No newline at end of file diff --git a/tests/legacy.php b/tests/legacy.php new file mode 100644 index 0000000..2feacae --- /dev/null +++ b/tests/legacy.php @@ -0,0 +1,11 @@ + +} diff --git a/tests/stream/decrypt.php b/tests/stream/decrypt.php new file mode 100644 index 0000000..ebb2d5a --- /dev/null +++ b/tests/stream/decrypt.php @@ -0,0 +1,62 @@ +