diff --git a/.travis.yml b/.travis.yml index 32b30dd..db09559 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,5 @@ sudo: false matrix: fast_finish: true - allow_failures: - - php: "7.0" script: ./test.sh diff --git a/README.md b/README.md index 039efca..0e9e90d 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,8 @@ This is a class for doing symmetric encryption in PHP. **Requires PHP 5.4 or new Implementation -------------- -Messages are encrypted with AES-128 in CBC mode and are authenticated with -HMAC-SHA256 (Encrypt-then-Mac). PKCS7 padding is used to pad the message to -a multiple of the block size. HKDF is used to split the user-provided key into +Messages are encrypted with AES-128 in CTR mode and are authenticated with +HMAC-SHA256 (Encrypt-then-Mac). HKDF is used to split the user-provided key into two keys: one for encryption, and the other for authentication. It is implemented using the `openssl_` and `hash_hmac` functions. diff --git a/autoload.php b/autoload.php index d73b52a..309cc98 100644 --- a/autoload.php +++ b/autoload.php @@ -34,5 +34,3 @@ require $file; } }); - -$crypto_exception_handler_object_dont_touch_me = new \Defuse\Crypto\ExceptionHandler; diff --git a/doc/02-Crypto.php.md b/doc/02-Crypto.php.md index 79ddeb1..b9fb7eb 100644 --- a/doc/02-Crypto.php.md +++ b/doc/02-Crypto.php.md @@ -3,8 +3,8 @@ Symmetric Key Encryption At a glance: -* **Cipher and Mode**: `AES-128-CBC` -* **Padding**: `PKCS#7` +* **Cipher and Mode**: `AES-256-CTR` +* **Padding**: None (CTR mode doesn't pad) * **Authentication**: `HMAC-SHA-256` * **Construction**: `Encrypt then MAC` * **Algorithm Backend**: `ext/openssl` @@ -37,9 +37,9 @@ try { // \Defuse\Crypto\Crypto\binToHex() // \Defuse\Crypto\Crypto\hexToBin() // -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely create a key'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely create a key'); } ``` @@ -51,9 +51,9 @@ Encrypting a Message $message = 'ATTACK AT DAWN'; try { $ciphertext = \Defuse\Crypto\Crypto::Encrypt($message, $key); -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform encryption'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform encryption'); } ``` @@ -64,16 +64,16 @@ Decrypting a Message ```php try { $decrypted = self::Decrypt($ciphertext, $key); -} catch (\Defuse\Crypto\Exception\InvalidCiphertext $ex) { // VERY IMPORTANT +} catch (\Defuse\Crypto\Exception\InvalidCiphertextException $ex) { // VERY IMPORTANT // Either: // 1. The ciphertext was modified by the attacker, // 2. The key is wrong, or // 3. $ciphertext is not a valid ciphertext or was corrupted. // Assume the worst. die('DANGER! DANGER! The ciphertext has been tampered with!'); -} catch (CryptoTestFailedException $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform decryption'); -} catch (CannotPerformOperationException $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform decryption'); } ``` @@ -86,16 +86,16 @@ Decrypting a Message Encrypted with version 1 of this Library ```php try { $decrypted = self::legacyDecrypt($ciphertext, $key); -} catch (\Defuse\Crypto\Exception\InvalidCiphertext $ex) { // VERY IMPORTANT +} catch (\Defuse\Crypto\Exception\InvalidCiphertextException $ex) { // VERY IMPORTANT // Either: // 1. The ciphertext was modified by the attacker, // 2. The key is wrong, or // 3. $ciphertext is not a valid ciphertext or was corrupted. // Assume the worst. die('DANGER! DANGER! The ciphertext has been tampered with!'); -} catch (CryptoTestFailedException $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform decryption'); -} catch (CannotPerformOperationException $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform decryption'); } -``` \ No newline at end of file +``` diff --git a/doc/03-File.php.md b/doc/03-File.php.md index a502fe0..5983401 100644 --- a/doc/03-File.php.md +++ b/doc/03-File.php.md @@ -3,7 +3,7 @@ Symmetric Key File Encryption At a glance: -* **Cipher and Mode**: `AES-128-CTR` +* **Cipher and Mode**: `AES-256-CTR` * **Padding**: None (CTR mode doesn't pad) * **Authentication**: `HMAC-SHA-256` * **Construction**: `Encrypt then MAC` @@ -37,9 +37,9 @@ try { // \Defuse\Crypto\File\binToHex() // \Defuse\Crypto\File\hexToBin() // -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely create a key'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely create a key'); } ``` @@ -62,9 +62,9 @@ try { $outputFilename, $key ); -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform encryption'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform encryption'); } ``` @@ -87,9 +87,9 @@ try { $outputFilename, $key ); -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform decryption'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform decryption'); } ``` @@ -108,9 +108,9 @@ $oFile = \fopen('image2.enc.jpg', 'wb'); try { \Defuse\Crypto\File::encryptResource($iFile, $oFile, $key); -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform encryption'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform encryption'); } ``` @@ -129,9 +129,9 @@ $oFile = \fopen('image2.dec.jpg', 'wb'); try { \Defuse\Crypto\File::decryptResource($iFile, $oFile, $key); -} catch (\Defuse\Crypto\Exception\CryptoTestFailed $ex) { +} catch (\Defuse\Crypto\Exception\CryptoTestFailedException $ex) { die('Cannot safely perform decryption'); -} catch (\Defuse\Crypto\Exception\CannotPerformOperation $ex) { +} catch (\Defuse\Crypto\Exception\CannotPerformOperationException $ex) { die('Cannot safely perform decryption'); } -``` \ No newline at end of file +``` diff --git a/benchmark.php b/other/benchmark.php similarity index 97% rename from benchmark.php rename to other/benchmark.php index c7b58ba..0d1f6f3 100644 --- a/benchmark.php +++ b/other/benchmark.php @@ -1,7 +1,7 @@ cipher_method = $config_array["cipher_method"]; + $this->block_byte_size = $config_array["block_byte_size"]; + $this->key_byte_size = $config_array["key_byte_size"]; + $this->salt_byte_size = $config_array["salt_byte_size"]; + $this->mac_byte_size = $config_array["mac_byte_size"]; + $this->hash_function_name = $config_array["hash_function_name"]; + $this->encryption_info_string = $config_array["encryption_info_string"]; + $this->authentication_info_string = $config_array["authentication_info_string"]; + + Core::ensureFunctionExists('openssl_get_cipher_methods'); + if (\in_array($this->cipher_method, \openssl_get_cipher_methods()) === false) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid OpenSSL cipher method." + ); + } + + if (!\is_int($this->block_byte_size) || $this->block_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid block byte size." + ); + } + + if (!\is_int($this->key_byte_size) || $this->key_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid key byte size." + ); + } + + if ($this->salt_byte_size !== false) { + if (!is_int($this->salt_byte_size) || $this->salt_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid salt byte size." + ); + } + } + + if (!\is_int($this->mac_byte_size) || $this->mac_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid MAC byte size." + ); + } + + if (\in_array($this->hash_function_name, \hash_algos()) === false) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid hash function name." + ); + } + + if (!\is_string($this->encryption_info_string) || $this->encryption_info_string === "") { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid encryption info string." + ); + } + + if (!\is_string($this->authentication_info_string) || $this->authentication_info_string === "") { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid authentication info string." + ); + } + } + + public function cipherMethod() + { + return $this->cipher_method; + } + + public function blockByteSize() + { + return $this->block_byte_size; + } + + public function keyByteSize() + { + return $this->key_byte_size; + } + + public function saltByteSize() + { + return $this->salt_byte_size; + } + + public function macByteSize() + { + return $this->mac_byte_size; + } + + public function hashFunctionName() + { + return $this->hash_function_name; + } + + public function encryptionInfoString() + { + return $this->encryption_info_string; + } + + public function authenticationInfoString() + { + return $this->authentication_info_string; + } +} diff --git a/src/Core.php b/src/Core.php index 2b0bdf1..0c2822f 100644 --- a/src/Core.php +++ b/src/Core.php @@ -2,80 +2,18 @@ namespace Defuse\Crypto; use \Defuse\Crypto\Exception as Ex; +use \Defuse\Crypto\Crypto; -class Core +final class Core { - const VERSION = "\xD3\xF5\x02\x00"; - const HEADER_VERSION_SIZE = 4; // This should never change + const HEADER_VERSION_SIZE = 4; /* This must never change. */ - /** - * Use this to generate a random encryption key. - * - * @return string - */ - public static function createNewRandomKey() - { - $valid = 0; - $config = self::getCoreVersionConfig(1, 0, $valid); - return self::secureRandom($config['KEY_BYTE_SIZE']); - } - /** - * 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; - } + const HEADER_MAGIC = "\xDE\xF5"; + const CURRENT_VERSION = "\xDE\xF5\x02\x00"; + const LEGACY_VERSION = "\xDE\xF5\x01\x00"; - /** - * 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; - } + const HEADER_MAGIC_FILE = "\xDE\xF4"; + const CURRENT_FILE_VERSION = "\xDE\xF4\x02\x00"; /** * Increment a counter (prevent nonce reuse) @@ -89,24 +27,48 @@ public static function incrementCounter($ctr, $inc, &$config) { static $ivsize = null; if ($ivsize === null) { - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); + if ($ivsize === false) { + throw new Ex\CannotPerformOperationException( + "Problem obtaining the correct nonce length." + ); + } + } + + if (self::ourStrlen($ctr) !== $ivsize) { + throw new Ex\CannotPerformOperationException( + "Trying to increment a nonce of the wrong size." + ); + } + + if (!is_int($inc)) { + throw new Ex\CannotPerformOperationException( + "Trying to increment nonce by a non-integer." + ); + } + + if ($inc < 0) { + throw new Ex\CannotPerformOperationException( + "Trying to increment nonce by a negative amount." + ); } /** * 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]); + $sum = \ord($ctr[$i]) + $inc; - $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; + /* Detect integer overflow and fail. */ + if (!is_int($sum)) { + throw new Ex\CannotPerformOperationException( + "Integer overflow in CTR mode nonce increment." + ); } - $inc = ($inc >> 8) & ~0; - ++$inc; + + $ctr[$i] = \chr($sum & 0xFF); + $inc = $sum >> 8; } return $ctr; } @@ -118,7 +80,7 @@ public static function incrementCounter($ctr, $inc, &$config) * @return string (raw binary) * @throws Ex\CannotPerformOperationException */ - protected static function secureRandom($octets) + public static function secureRandom($octets) { self::ensureFunctionExists('openssl_random_pseudo_bytes'); $secure = false; @@ -142,15 +104,11 @@ protected static function secureRandom($octets) * @return string * @throws Ex\CannotPerformOperationException */ - protected static function HKDF($hash, $ikm, $length, $info = '', $salt = null, $config = null) + public 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 = $config->macByteSize(); + if ($hash != $config->hashFunctionName()) { $digest_length = self::ourStrlen(\hash_hmac($hash, '', '', true)); } // Sanity-check the desired output length. @@ -208,9 +166,9 @@ protected static function HKDF($hash, $ikm, $length, $info = '', $salt = null, $ * @param string $expected string (raw binary) * @param string $given string (raw binary) * @return boolean - * @throws Ex\CannotPerformOperation + * @throws Ex\CannotPerformOperationException */ - protected static function hashEquals($expected, $given) + public static function hashEquals($expected, $given) { static $native = null; if ($native === null) { @@ -219,11 +177,6 @@ protected static function hashEquals($expected, $given) 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 @@ -237,9 +190,9 @@ protected static function hashEquals($expected, $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); + $blind = self::secureRandom(32); + $message_compare = hash_hmac('sha256', $given, $blind); + $correct_compare = hash_hmac('sha256', $expected, $blind); return $correct_compare === $message_compare; } /** @@ -248,7 +201,7 @@ protected static function hashEquals($expected, $given) * @param string $name * @throws Ex\CannotPerformOperationException */ - protected static function ensureConstantExists($name) + public static function ensureConstantExists($name) { if (!\defined($name)) { throw new Ex\CannotPerformOperationException(); @@ -261,7 +214,7 @@ protected static function ensureConstantExists($name) * @param string $name Function name * @throws Ex\CannotPerformOperationException */ - protected static function ensureFunctionExists($name) + public static function ensureFunctionExists($name) { if (!\function_exists($name)) { throw new Ex\CannotPerformOperationException(); @@ -281,7 +234,7 @@ protected static function ensureFunctionExists($name) * @param string $str * @return int */ - protected static function ourStrlen($str) + public static function ourStrlen($str) { static $exists = null; if ($exists === null) { @@ -307,7 +260,7 @@ protected static function ourStrlen($str) * @param int $length * @return string */ - protected static function ourSubstr($str, $start, $length = null) + public static function ourSubstr($str, $start, $length = null) { static $exists = null; if ($exists === null) { @@ -336,59 +289,4 @@ protected static function ourSubstr($str, $start, $length = null) } } - /** - * 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 6d6cad4..6a38bd4 100755 --- a/src/Crypto.php +++ b/src/Crypto.php @@ -3,6 +3,12 @@ use \Defuse\Crypto\Exception as Ex; +use \Defuse\Crypto\Core; +use \Defuse\Crypto\Key; +use \Defuse\Crypto\Encoding; +use \Defuse\Crypto\RuntimeTests; +use \Defuse\Crypto\Config; + /* * PHP Encryption Library * Copyright (c) 2014-2015, Taylor Hornby @@ -30,13 +36,10 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ -final class Crypto extends Core +class Crypto { // Ciphertext format: [____VERSION____][____HMAC____][____IV____][____CIPHERTEXT____]. // Legacy format: [____HMAC____][____IV____][____CIPHERTEXT____]. - const CIPHER_METHOD = 'aes-128-cbc'; - - const LEGACY_VERSION = "\xD3\xF5\x01\x00"; /** * Use this to generate a random encryption key. @@ -45,8 +48,7 @@ final class Crypto extends Core */ public static function createNewRandomKey() { - $config = self::getVersionConfig(self::VERSION); - return self::secureRandom($config['KEY_BYTE_SIZE']); + return Key::CreateNewRandomKey(Core::CURRENT_VERSION); } /** @@ -54,151 +56,177 @@ public static function createNewRandomKey() * * $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. + * You MUST catch exceptions thrown by this function. Read the docs. * * @param string $plaintext * @param string $key * @param boolean $raw_binary * @return string * @throws Ex\CannotPerformOperationException + * @throws Ex\CryptoTestFailedException */ public static function encrypt($plaintext, $key, $raw_binary = false) { - self::runtimeTest(); - $config = self::getVersionConfig(parent::VERSION); + RuntimeTests::runtimeTest(); + + /* Attempt to validate that the key was generated safely. */ + if (!is_a($key, "\Defuse\Crypto\Key")) { + throw new Ex\CannotPerformOperationException( + "The given key is not a valid Key object." + ); + } + $key = $key->getRawBytes(); - if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { + $config = self::getVersionConfigFromHeader(Core::CURRENT_VERSION, Core::CURRENT_VERSION); + + if (Core::ourStrlen($key) !== $config->keyByteSize()) { throw new Ex\CannotPerformOperationException("Key is the wrong size."); } - $salt = self::secureRandom($config['SALT_SIZE']); + $salt = Core::secureRandom($config->saltByteSize()); // Generate a sub-key for encryption. - $ekey = self::HKDF( - $config['HASH_FUNCTION'], + $ekey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['ENCRYPTION_INFO'], + $config->keyByteSize(), + $config->encryptionInfoString(), $salt, $config ); // Generate a sub-key for authentication and apply the HMAC. - $akey = self::HKDF( - $config['HASH_FUNCTION'], + $akey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['AUTHENTICATION_INFO'], + $config->keyByteSize(), + $config->authenticationInfoString(), $salt, $config ); // Generate a random initialization vector. - self::ensureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + Core::ensureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); if ($ivsize === false || $ivsize <= 0) { throw new Ex\CannotPerformOperationException( "Could not get the IV length from OpenSSL" ); } - $iv = self::secureRandom($ivsize); + $iv = Core::secureRandom($ivsize); $ciphertext = $salt . $iv . self::plainEncrypt($plaintext, $ekey, $iv, $config); - $auth = \hash_hmac($config['HASH_FUNCTION'], parent::VERSION . $ciphertext, $akey, true); + $auth = \hash_hmac($config->hashFunctionName(), Core::CURRENT_VERSION . $ciphertext, $akey, true); // We're now appending the header as of 2.00 - $ciphertext = parent::VERSION . $auth . $ciphertext; + $ciphertext = Core::CURRENT_VERSION . $auth . $ciphertext; if ($raw_binary) { return $ciphertext; } - return self::binToHex($ciphertext); + return Encoding::binToHex($ciphertext); } /** * Decrypts a ciphertext. * $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. + * You MUST catch exceptions thrown by this function. Read the docs. * * @param string $ciphertext * @param string $key * @param boolean $raw_binary - * @return type + * @return string * @throws Ex\CannotPerformOperationException + * @throws Ex\CryptoTestFailedException * @throws Ex\InvalidCiphertextException */ public static function decrypt($ciphertext, $key, $raw_binary = false) { - self::runtimeTest(); + RuntimeTests::runtimeTest(); + + /* Attempt to validate that the key was generated safely. */ + if (!is_a($key, "\Defuse\Crypto\Key")) { + throw new Ex\CannotPerformOperationException( + "The given key is not a valid Key object." + ); + } + $key = $key->getRawBytes(); + if (!$raw_binary) { - $ciphertext = self::hexToBin($ciphertext); + try { + $ciphertext = Encoding::hexToBin($ciphertext); + } catch (\RangeException $ex) { + throw new Ex\InvalidCiphertextException( + "Ciphertext has invalid hex encoding." + ); + } } // Grab the header tag - $version = self::ourSubstr($ciphertext, 0, parent::HEADER_VERSION_SIZE); + $version = Core::ourSubstr($ciphertext, 0, Core::HEADER_VERSION_SIZE); // Load the configuration for this version - $config = self::getVersionConfig($version); + $config = self::getVersionConfigFromHeader($version, Core::CURRENT_VERSION); // Now let's operate on the remainder of the ciphertext as normal - $ciphertext = self::ourSubstr($ciphertext, parent::HEADER_VERSION_SIZE, null); + $ciphertext = Core::ourSubstr($ciphertext, Core::HEADER_VERSION_SIZE, null); // Extract the HMAC from the front of the ciphertext. - if (self::ourStrlen($ciphertext) <= $config['MAC_BYTE_SIZE']) { + if (Core::ourStrlen($ciphertext) <= $config->macByteSize()) { throw new Ex\InvalidCiphertextException( "Ciphertext is too short." ); } - $hmac = self::ourSubstr( + $hmac = Core::ourSubstr( $ciphertext, 0, - $config['MAC_BYTE_SIZE'] + $config->macByteSize() ); if ($hmac === false) { throw new Ex\CannotPerformOperationException(); } - $salt = self::ourSubstr( + $salt = Core::ourSubstr( $ciphertext, - $config['MAC_BYTE_SIZE'], - $config['SALT_SIZE'] + $config->macByteSize(), + $config->saltByteSize() ); if ($salt === false) { throw new Ex\CannotPerformOperationException(); } - $ciphertext = self::ourSubstr( + $ciphertext = Core::ourSubstr( $ciphertext, - $config['MAC_BYTE_SIZE'] + $config['SALT_SIZE'] + $config->macByteSize() + $config->saltByteSize() ); 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'], $salt, $config); + $akey = Core::HKDF($config->hashFunctionName(), $key, $config->keyByteSize(), $config->authenticationInfoString(), $salt, $config); - if (self::verifyHMAC($hmac, $version . $salt . $ciphertext, $akey)) { + if (self::verifyHMAC($hmac, $version . $salt . $ciphertext, $akey, $config)) { // Regenerate the same encryption sub-key. - $ekey = self::HKDF($config['HASH_FUNCTION'], $key, $config['KEY_BYTE_SIZE'], $config['ENCRYPTION_INFO'], $salt, $config); + $ekey = Core::HKDF($config->hashFunctionName(), $key, $config->keyByteSize(), $config->encryptionInfoString(), $salt, $config); // Extract the initialization vector from the ciphertext. - self::EnsureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + Core::EnsureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); if ($ivsize === false || $ivsize <= 0) { throw new Ex\CannotPerformOperationException( "Could not get the IV length from OpenSSL" ); } - if (self::ourStrlen($ciphertext) <= $ivsize) { + if (Core::ourStrlen($ciphertext) <= $ivsize) { throw new Ex\InvalidCiphertextException( "Ciphertext is too short." ); } - $iv = self::ourSubstr($ciphertext, 0, $ivsize); + $iv = Core::ourSubstr($ciphertext, 0, $ivsize); if ($iv === false) { throw new Ex\CannotPerformOperationException(); } - $ciphertext = self::ourSubstr($ciphertext, $ivsize); + $ciphertext = Core::ourSubstr($ciphertext, $ivsize); if ($ciphertext === false) { throw new Ex\CannotPerformOperationException(); } @@ -223,73 +251,74 @@ public static function decrypt($ciphertext, $key, $raw_binary = false) * * $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. + * You MUST catch exceptions thrown by this function. Read the docs. * * @param string $ciphertext * @param string $key - * @return type + * @return string * @throws Ex\CannotPerformOperationException + * @throws Ex\CryptoTestFailedException * @throws Ex\InvalidCiphertextException */ public static function legacyDecrypt($ciphertext, $key) { - self::runtimeTest(); - $config = self::getVersionConfig(self::LEGACY_VERSION); + RuntimeTests::runtimeTest(); + $config = self::getVersionConfigFromHeader(Core::LEGACY_VERSION, Core::LEGACY_VERSION); // Extract the HMAC from the front of the ciphertext. - if (self::ourStrlen($ciphertext) <= $config['MAC_BYTE_SIZE']) { + if (Core::ourStrlen($ciphertext) <= $config->macByteSize()) { throw new Ex\InvalidCiphertextException( "Ciphertext is too short." ); } - $hmac = self::ourSubstr($ciphertext, 0, $config['MAC_BYTE_SIZE']); + $hmac = Core::ourSubstr($ciphertext, 0, $config->macByteSize()); if ($hmac === false) { throw new Ex\CannotPerformOperationException(); } - $ciphertext = self::ourSubstr($ciphertext, $config['MAC_BYTE_SIZE']); + $ciphertext = Core::ourSubstr($ciphertext, $config->macByteSize()); if ($ciphertext === false) { throw new Ex\CannotPerformOperationException(); } // Regenerate the same authentication sub-key. - $akey = self::HKDF( - $config['HASH_FUNCTION'], + $akey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['AUTHENTICATION_INFO'], + $config->keyByteSize(), + $config->authenticationInfoString(), null, $config ); - if (self::verifyHMAC($hmac, $ciphertext, $akey)) { + if (self::verifyHMAC($hmac, $ciphertext, $akey, $config)) { // Regenerate the same encryption sub-key. - $ekey = self::HKDF( - $config['HASH_FUNCTION'], + $ekey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['ENCRYPTION_INFO'], + $config->keyByteSize(), + $config->encryptionInfoString(), null, $config ); // Extract the initialization vector from the ciphertext. - self::EnsureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + Core::EnsureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); if ($ivsize === false || $ivsize <= 0) { throw new Ex\CannotPerformOperationException( "Could not get the IV length from OpenSSL" ); } - if (self::ourStrlen($ciphertext) <= $ivsize) { + if (Core::ourStrlen($ciphertext) <= $ivsize) { throw new Ex\InvalidCiphertextException( "Ciphertext is too short." ); } - $iv = self::ourSubstr($ciphertext, 0, $ivsize); + $iv = Core::ourSubstr($ciphertext, 0, $ivsize); if ($iv === false) { throw new Ex\CannotPerformOperationException(); } - $ciphertext = self::ourSubstr($ciphertext, $ivsize); + $ciphertext = Core::ourSubstr($ciphertext, $ivsize); if ($ciphertext === false) { throw new Ex\CannotPerformOperationException(); } @@ -309,68 +338,8 @@ public static function legacyDecrypt($ciphertext, $key) } } - /* - * Runs tests. - * Raises CannotPerformOperationExceptionException or CryptoTestFailedExceptionException if - * one of the tests fail. If any tests fails, your system is not capable of - * performing encryption, so make sure you fail safe in that case. - */ - public static function runtimeTest() - { - // 0: Tests haven't been run yet. - // 1: Tests have passed. - // 2: Tests are running right now. - // 3: Tests have failed. - static $test_state = 0; - - $config = self::getVersionConfig(parent::VERSION); - - if ($test_state === 1 || $test_state === 2) { - return; - } - - if ($test_state === 3) { - /* If an intermittent problem caused a test to fail previously, we - * want that to be indicated to the user with every call to this - * library. This way, if the user first does something they really - * don't care about, and just ignores all exceptions, they won't get - * screwed when they then start to use the library for something - * they do care about. */ - throw new Ex\CryptoTestFailedException("Tests failed previously."); - } - - try { - $test_state = 2; - - self::ensureFunctionExists('openssl_get_cipher_methods'); - if (\in_array($config['CIPHER_METHOD'], \openssl_get_cipher_methods()) === false) { - throw new Ex\CryptoTestFailedException("Cipher method not supported."); - } - - self::AESTestVector($config); - self::HMACTestVector($config); - self::HKDFTestVector($config); - - self::testEncryptDecrypt($config); - if (self::ourStrlen(self::createNewRandomKey()) != $config['KEY_BYTE_SIZE']) { - throw new Ex\CryptoTestFailedException(); - } - - if ($config['ENCRYPTION_INFO'] == $config['AUTHENTICATION_INFO']) { - throw new Ex\CryptoTestFailedException(); - } - } catch (Ex\CryptoTestFailedException $ex) { - // Do this, otherwise it will stay in the "tests are running" state. - $test_state = 3; - throw $ex; - } - - // Change this to '0' make the tests always re-run (for benchmarking). - $test_state = 1; - } - /** - * Never call this method directly! + * You MUST NOT call this method directly. * * Unauthenticated message encryption. * @@ -381,13 +350,13 @@ public static function runtimeTest() * @return string * @throws Ex\CannotPerformOperationException */ - private static function plainEncrypt($plaintext, $key, $iv, $config) + protected static function plainEncrypt($plaintext, $key, $iv, $config) { - self::ensureConstantExists("OPENSSL_RAW_DATA"); - self::ensureFunctionExists("openssl_encrypt"); + Core::ensureConstantExists("OPENSSL_RAW_DATA"); + Core::ensureFunctionExists("openssl_encrypt"); $ciphertext = \openssl_encrypt( $plaintext, - $config['CIPHER_METHOD'], + $config->cipherMethod(), $key, OPENSSL_RAW_DATA, $iv @@ -403,23 +372,24 @@ private static function plainEncrypt($plaintext, $key, $iv, $config) } /** - * Never call this method directly! + * You MUST NOT call this method directly. * * Unauthenticated message deryption. * * @param string $ciphertext * @param string $key * @param string $iv + * @param array $config * @return string * @throws Ex\CannotPerformOperationException */ - private static function plainDecrypt($ciphertext, $key, $iv, $config) + protected static function plainDecrypt($ciphertext, $key, $iv, $config) { - self::ensureConstantExists("OPENSSL_RAW_DATA"); - self::ensureFunctionExists("openssl_decrypt"); + Core::ensureConstantExists("OPENSSL_RAW_DATA"); + Core::ensureFunctionExists("openssl_decrypt"); $plaintext = \openssl_decrypt( $ciphertext, - $config['CIPHER_METHOD'], + $config->cipherMethod(), $key, OPENSSL_RAW_DATA, $iv @@ -434,213 +404,103 @@ private static function plainDecrypt($ciphertext, $key, $iv, $config) } /** - * Verify a HMAC without crypto side-channels + * Verify an HMAC without timing/cache side-channel leakage. * - * @staticvar boolean $native Use native hash_equals()? * @param string $correct_hmac HMAC string (raw binary) * @param string $message Ciphertext (raw binary) * @param string $key Authentication key (raw binary) * @return boolean * @throws Ex\CannotPerformOperationException */ - private static function verifyHMAC($correct_hmac, $message, $key) - { - static $config = null; - if ($config === null) { - $config = self::getVersionConfig(parent::VERSION); - } - $message_hmac = \hash_hmac($config['HASH_FUNCTION'], $message, $key, true); - return self::hashEquals($correct_hmac, $message_hmac); - } - - private static function testEncryptDecrypt($config) + protected static function verifyHMAC($correct_hmac, $message, $key, $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, true); - try { - $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) { - throw new Ex\CryptoTestFailedException(); - } - - // Modifying the ciphertext: Appending a string. - try { - self::decrypt($ciphertext . "a", $key, true); - throw new Ex\CryptoTestFailedException(); - } catch (Ex\InvalidCiphertextException $e) { /* expected */ } - - // Modifying the ciphertext: Changing an IV byte. - try { - $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, true); - $wrong_key = self::createNewRandomKey(); - try { - 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", $config['MAC_BYTE_SIZE'] - 1); - try { - self::decrypt($ciphertext, $key, true); - throw new Ex\CryptoTestFailedException(); - } catch (Ex\InvalidCiphertextException $e) { /* expected */ } + $message_hmac = \hash_hmac($config->hashFunctionName(), $message, $key, true); + return Core::hashEquals($correct_hmac, $message_hmac); } /** - * Run-time testing + * Get the encryption configuration based on the version in a header. * - * @throws Ex\CryptoTestFailedException + * @param string $header The header to read the version number from. + * @param string $min_ver_header The header of the minimum version number allowed. + * @return array + * @throws Ex\InvalidCiphertextException */ - private static function HKDFTestVector($config) + public static function getVersionConfigFromHeader($header, $min_ver_header) { - // HKDF test vectors from RFC 5869 - if (empty($config)) { - $config = self::getVersionConfig(parent::VERSION); - } - - // Test Case 1 - $ikm = \str_repeat("\x0b", 22); - $salt = self::hexToBin("000102030405060708090a0b0c"); - $info = self::hexToBin("f0f1f2f3f4f5f6f7f8f9"); - $length = 42; - $okm = self::hexToBin( - "3cb25f25faacd57a90434f64d0362f2a" . - "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" . - "34007208d5b887185865" - ); - $computed_okm = self::HKDF("sha256", $ikm, $length, $info, $salt, $config); - if ($computed_okm !== $okm) { - throw new Ex\CryptoTestFailedException(); + if (Core::ourSubstr($header, 0, 2) !== Core::ourSubstr(Core::HEADER_MAGIC, 0, 2)) { + throw new Ex\InvalidCiphertextException( + "Ciphertext has a bad magic number." + ); } - // Test Case 7 - $ikm = \str_repeat("\x0c", 22); - $length = 42; - $okm = self::hexToBin( - "2c91117204d745f3500d636a62f64f0a" . - "b3bae548aa53d423b0d1f27ebba6f5e5" . - "673a081d70cce7acfc48" - ); - $computed_okm = self::HKDF("sha1", $ikm, $length, '', null, $config); - if ($computed_okm !== $okm) { - throw new Ex\CryptoTestFailedException(); - } + $major = \ord($header[2]); + $minor = \ord($header[3]); - } + $min_major = \ord($min_ver_header[2]); + $min_minor = \ord($min_ver_header[3]); - /** - * Run-Time tests - * - * @throws Ex\CryptoTestFailedException - */ - 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($config['HASH_FUNCTION'], $data, $key) !== $correct) { - throw new Ex\CryptoTestFailedException(); + if ($major < $min_major || ($major === $min_major && $minor < $min_minor) ) { + throw new Ex\InvalidCiphertextException( + "Ciphertext is requesting an insecure fallback." + ); } - } - /** - * Run-time tests - * - * @throws Ex\CryptoTestFailedException - */ - private static function AESTestVector($config) - { - // 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::hexToBin( - "874d6191b620e3261bef6864990db6ce" . - "9806f66b7970fdff8617187bb9fffdff" . - "5ae4df3edbd5d35e5b4f09020db03eab" . - "1e031dda2fbe03d1792170a0f3009cee" - ); - - $config = self::getVersionConfig(parent::VERSION); - - $computed_ciphertext = self::plainEncrypt($plaintext, $key, $iv, $config); - if ($computed_ciphertext !== $ciphertext) { - throw new Ex\CryptoTestFailedException(); - } + $config = self::getVersionConfigFromMajorMinor($major, $minor); - $computed_plaintext = self::plainDecrypt($ciphertext, $key, $iv, $config); - if ($computed_plaintext !== $plaintext) { - throw new Ex\CryptoTestFailedException(); - } + return $config; } /** - * Take a 4-byte header and get meaningful version information out of it. - * Common configuration options should go in Core.php * - * @param string $header + * @param int $major The major version number. + * @param int $minor The minor version number. + * @return array + * @throws Ex\InvalidCiphertextException */ - protected static function getVersionConfig($header) + protected static function getVersionConfigFromMajorMinor($major, $minor) { - $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 new Config([ + 'cipher_method' => 'aes-256-ctr', + 'block_byte_size' => 16, + 'key_byte_size' => 32, + 'salt_byte_size' => 16, + 'hash_function_name' => 'sha256', + 'mac_byte_size' => 32, + 'encryption_info_string' => 'DefusePHP|V2|KeyForEncryption', + 'authentication_info_string' => 'DefusePHP|V2|KeyForAuthentication' + ]); + default: + throw new Ex\InvalidCiphertextException( + "Unsupported ciphertext version." + ); } + } elseif ($major === 1) { + switch ($minor) { + case 0: + return new Config([ + 'cipher_method' => 'aes-128-cbc', + 'block_byte_size' => 16, + 'key_byte_size' => 16, + 'salt_byte_size' => false, + 'hash_function_name' => 'sha256', + 'mac_byte_size' => 32, + 'encryption_info_string' => 'DefusePHP|KeyForEncryption', + 'authentication_info_string' => 'DefusePHP|KeyForAuthentication' + ]); + default: + throw new Ex\InvalidCiphertextException( + "Unsupported ciphertext version." + ); + } + } else { + throw new Ex\InvalidCiphertextException( + "Unsupported ciphertext version." + ); } - - if ($valid !== 0) { - throw new Ex\InvalidCiphertextException('Unknown ciphertext version'); - } - return $config; } + } diff --git a/src/Encoding.php b/src/Encoding.php new file mode 100644 index 0000000..3e4617a --- /dev/null +++ b/src/Encoding.php @@ -0,0 +1,67 @@ +> 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 = Core::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( + 'Encoding::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; + } + +} diff --git a/src/ExceptionHandler.php b/src/ExceptionHandler.php deleted file mode 100644 index 8ef4a45..0000000 --- a/src/ExceptionHandler.php +++ /dev/null @@ -1,46 +0,0 @@ -rethrow = $ex; - } - } - - public function __destruct() { - if ($this->rethrow) { - throw $this->rethrow; - } - } -} diff --git a/src/File.php b/src/File.php index 9199389..3db66bb 100644 --- a/src/File.php +++ b/src/File.php @@ -3,6 +3,9 @@ use \Defuse\Crypto\Exception as Ex; +use \Defuse\Crypto\Core; +use \Defuse\Crypto\FileConfig; + /* * PHP Encryption Library * Copyright (c) 2014-2015, Taylor Hornby @@ -34,7 +37,7 @@ * 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 +final class File implements StreamInterface { /** * Use this to generate a random encryption key. @@ -43,8 +46,11 @@ final class File extends Core implements StreamInterface */ public static function createNewRandomKey() { - $config = self::getVersionConfig(self::VERSION); - return self::secureRandom($config['KEY_BYTE_SIZE']); + $config = self::getFileVersionConfigFromHeader( + Core::CURRENT_FILE_VERSION, + Core::CURRENT_FILE_VERSION + ); + return Core::secureRandom($config->keyByteSize()); } /** @@ -76,7 +82,7 @@ public static function encryptFile($inputFilename, $outputFilename, $key) */ $if = \fopen($inputFilename, 'rb'); if ($if === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot open input file for encrypting' ); } @@ -87,7 +93,8 @@ public static function encryptFile($inputFilename, $outputFilename, $key) */ $of = \fopen($outputFilename, 'wb'); if ($of === false) { - throw new Ex\CannotPerformOperation( + fclose($if); + throw new Ex\CannotPerformOperationException( 'Cannot open output file for encrypting' ); } @@ -96,18 +103,24 @@ public static function encryptFile($inputFilename, $outputFilename, $key) /** * Use encryptResource() to actually write the encrypted data to $of */ - $encrypted = self::encryptResource($if, $of, $key); + try { + $encrypted = self::encryptResource($if, $of, $key); + } catch (\Ex\CryptoException $ex) { + fclose($if); + fclose($of); + throw $ex; + } /** * Close handles */ if (\fclose($if) === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot close input file for encrypting' ); } if (\fclose($of) === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot close input file for encrypting' ); } @@ -147,7 +160,7 @@ public static function decryptFile($inputFilename, $outputFilename, $key) */ $if = \fopen($inputFilename, 'rb'); if ($if === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot open input file for decrypting' ); } @@ -158,7 +171,8 @@ public static function decryptFile($inputFilename, $outputFilename, $key) */ $of = \fopen($outputFilename, 'wb'); if ($of === false) { - throw new Ex\CannotPerformOperation( + fclose($if); + throw new Ex\CannotPerformOperationException( 'Cannot open output file for decrypting' ); } @@ -167,18 +181,24 @@ public static function decryptFile($inputFilename, $outputFilename, $key) /** * Use decryptResource() to actually write the decrypted data to $of */ - $decrypted = self::decryptResource($if, $of, $key); + try { + $decrypted = self::decryptResource($if, $of, $key); + } catch (\Ex\CryptoException $ex) { + fclose($if); + fclose($of); + throw $ex; + } /** * Close handles */ if (\fclose($if) === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot close input file for decrypting' ); } if (\fclose($of) === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot close input file for decrypting' ); } @@ -211,43 +231,46 @@ public static function encryptResource($inputHandle, $outputHandle, $key) 'Output handle must be a resource!' ); } - $config = self::getVersionConfig(parent::VERSION); + $config = self::getFileVersionConfigFromHeader( + Core::CURRENT_FILE_VERSION, + Core::CURRENT_FILE_VERSION + ); // Let's add this check before anything - if (!\in_array($config['HASH_FUNCTION'], \hash_algos())) { - throw new Ex\CannotPerformOperation( + if (!\in_array($config->hashFunctionName(), \hash_algos())) { + throw new Ex\CannotPerformOperationException( 'The specified hash function does not exist' ); } // Sanity check; key must be the appropriate length! - if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { + if (Core::ourStrlen($key) !== $config->keyByteSize()) { throw new Ex\InvalidInput( - 'Invalid key length. Keys should be '.$config['KEY_BYTE_SIZE'].' bytes long.' + 'Invalid key length. Keys should be '.$config->keyByteSize().' bytes long.' ); } /** * Let's split our keys */ - $file_salt = self::secureRandom($config['SALT_SIZE']); + $file_salt = Core::secureRandom($config->saltByteSize()); // $ekey -- Encryption Key -- used for AES - $ekey = self::HKDF( - $config['HASH_FUNCTION'], + $ekey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['ENCRYPTION_INFO'], + $config->keyByteSize(), + $config->encryptionInfoString(), $file_salt, $config ); // $akey -- Authentication Key -- used for HMAC - $akey = self::HKDF( - $config['HASH_FUNCTION'], + $akey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['AUTHENTICATION_INFO'], + $config->keyByteSize(), + $config->authenticationInfoString(), $file_salt, $config ); @@ -255,24 +278,24 @@ public static function encryptResource($inputHandle, $outputHandle, $key) /** * Generate a random initialization vector. */ - self::ensureFunctionExists("openssl_cipher_iv_length"); - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + Core::ensureFunctionExists("openssl_cipher_iv_length"); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); if ($ivsize === false || $ivsize <= 0) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Improper IV size' ); } - $iv = self::secureRandom($ivsize); + $iv = Core::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 + Core::CURRENT_FILE_VERSION . $file_salt . $iv, + Core::HEADER_VERSION_SIZE + $config->saltByteSize() + $ivsize ) === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot write to output file' ); } @@ -281,9 +304,9 @@ public static function encryptResource($inputHandle, $outputHandle, $key) * 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); + $hmac = \hash_init($config->hashFunctionName(), HASH_HMAC, $akey); if ($hmac === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot initialize a hash context' ); } @@ -298,12 +321,12 @@ public static function encryptResource($inputHandle, $outputHandle, $key) * How much do we increase the counter after each buffered encryption to * prevent nonce reuse? */ - $inc = $config['BUFFER'] / $config['BLOCK_SIZE']; + $inc = $config->bufferByteSize() / $config->blockByteSize(); /** * Let's MAC our salt and IV/nonce */ - \hash_update($hmac, parent::VERSION); + \hash_update($hmac, Core::CURRENT_FILE_VERSION); \hash_update($hmac, $file_salt); \hash_update($hmac, $iv); @@ -311,20 +334,20 @@ public static function encryptResource($inputHandle, $outputHandle, $key) * Iterate until we reach the end of the input file */ while (!\feof($inputHandle)) { - $read = \fread($inputHandle, $config['BUFFER']); + $read = \fread($inputHandle, $config->bufferByteSize()); if ($read === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot read input file' ); } - $thisIv = self::incrementCounter($thisIv, $inc, $config); + $thisIv = Core::incrementCounter($thisIv, $inc, $config); /** * Perform the AES encryption. Encrypts the plaintext. */ $encrypted = \openssl_encrypt( $read, - $config['CIPHER_METHOD'], + $config->cipherMethod(), $ekey, OPENSSL_RAW_DATA, $thisIv @@ -333,7 +356,7 @@ public static function encryptResource($inputHandle, $outputHandle, $key) * Check that the encryption was performed successfully */ if ($encrypted === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'OpenSSL encryption error' ); } @@ -341,8 +364,8 @@ public static function encryptResource($inputHandle, $outputHandle, $key) /** * Write the ciphertext to the output file */ - if (\fwrite($outputHandle, $encrypted, self::ourStrlen($encrypted)) === false) { - throw new Ex\CannotPerformOperation( + if (\fwrite($outputHandle, $encrypted, Core::ourStrlen($encrypted)) === false) { + throw new Ex\CannotPerformOperationException( 'Cannot write to output file during encryption' ); } @@ -356,9 +379,9 @@ public static function encryptResource($inputHandle, $outputHandle, $key) // Now let's get our HMAC and append it $finalHMAC = \hash_final($hmac, true); - $appended = \fwrite($outputHandle, $finalHMAC, $config['MAC_BYTE_SIZE']); + $appended = \fwrite($outputHandle, $finalHMAC, $config->macByteSize()); if ($appended === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot write to output file' ); } @@ -388,33 +411,36 @@ public static function decryptResource($inputHandle, $outputHandle, $key) ); } - // Parse the header, ensuring we get 4 bytes + // Parse the header. $header = ''; - $remaining = 4; + $remaining = Core::HEADER_VERSION_SIZE; do { $header .= \fread($inputHandle, $remaining); - $remaining = 4 - self::ourStrlen($header); + $remaining = Core::HEADER_VERSION_SIZE - Core::ourStrlen($header); } while ($remaining > 0); - $config = self::getVersionConfig($header); + $config = self::getFileVersionConfigFromHeader( + $header, + Core::CURRENT_FILE_VERSION + ); // Let's add this check before anything - if (!\in_array($config['HASH_FUNCTION'], \hash_algos())) { - throw new Ex\CannotPerformOperation( + if (!\in_array($config->hashFunctionName(), \hash_algos())) { + throw new Ex\CannotPerformOperationException( 'The specified hash function does not exist' ); } // Sanity check; key must be the appropriate length! - if (self::ourStrlen($key) !== $config['KEY_BYTE_SIZE']) { + if (Core::ourStrlen($key) !== $config->keyByteSize()) { throw new Ex\InvalidInput( - 'Invalid key length. Keys should be '.$config['KEY_BYTE_SIZE'].' bytes long.' + 'Invalid key length. Keys should be '.$config->keyByteSize().' bytes long.' ); } // Let's grab the file salt. - $file_salt = \fread($inputHandle, $config['SALT_SIZE']); + $file_salt = \fread($inputHandle, $config->saltByteSize()); if ($file_salt === false ) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot read input file' ); } @@ -430,11 +456,11 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * * $ekey -- Encryption Key -- used for AES */ - $ekey = self::HKDF( - $config['HASH_FUNCTION'], + $ekey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['ENCRYPTION_INFO'], + $config->keyByteSize(), + $config->encryptionInfoString(), $file_salt, $config ); @@ -442,11 +468,11 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * $akey -- Authentication Key -- used for HMAC */ - $akey = self::HKDF( - $config['HASH_FUNCTION'], + $akey = Core::HKDF( + $config->hashFunctionName(), $key, - $config['KEY_BYTE_SIZE'], - $config['AUTHENTICATION_INFO'], + $config->keyByteSize(), + $config->authenticationInfoString(), $file_salt, $config ); @@ -456,16 +482,16 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * * It should be the first N blocks of the file (N = 16) */ - $ivsize = \openssl_cipher_iv_length($config['CIPHER_METHOD']); + $ivsize = \openssl_cipher_iv_length($config->cipherMethod()); $iv = \fread($inputHandle, $ivsize); if ($iv === false ) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( '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']; + $inc = $config->bufferByteSize() / $config->blockByteSize(); $thisIv = $iv; @@ -474,8 +500,8 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * * 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( + if (\fseek($inputHandle, (-1 * $config->macByteSize()), SEEK_END) === false) { + throw new Ex\CannotPerformOperationException( 'Cannot seek to beginning of MAC within input file' ); } @@ -483,16 +509,16 @@ public static function decryptResource($inputHandle, $outputHandle, $key) // Grab our last position of ciphertext before we read the MAC $cipher_end = \ftell($inputHandle); if ($cipher_end === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( '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']); + $stored_mac = \fread($inputHandle, $config->macByteSize()); if ($stored_mac === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot read input file' ); } @@ -500,9 +526,9 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * We begin recalculating the HMAC for the entire file... */ - $hmac = \hash_init($config['HASH_FUNCTION'], HASH_HMAC, $akey); + $hmac = \hash_init($config->hashFunctionName(), HASH_HMAC, $akey); if ($hmac === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot initialize a hash context' ); } @@ -510,8 +536,8 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * 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( + if (\fseek($inputHandle, Core::HEADER_VERSION_SIZE, SEEK_SET) === false) { + throw new Ex\CannotPerformOperationException( 'Cannot read seek within input file' ); } @@ -519,8 +545,8 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * 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( + if (\fseek($inputHandle, $config->saltByteSize() + $ivsize, SEEK_CUR) === false) { + throw new Ex\CannotPerformOperationException( 'Cannot read seek input file to beginning of ciphertext' ); } @@ -542,7 +568,7 @@ public static function decryptResource($inputHandle, $outputHandle, $key) */ $pos = \ftell($inputHandle); if ($pos === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Could not get current position in input file during decryption' ); } @@ -551,14 +577,14 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * 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) { + if ($pos + $config->bufferByteSize() >= $cipher_end) { $break = true; $read = \fread($inputHandle, $cipher_end - $pos + 1); } else { - $read = \fread($inputHandle, $config['BUFFER']); + $read = \fread($inputHandle, $config->bufferByteSize()); } if ($read === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Could not read input file during decryption' ); } @@ -572,7 +598,7 @@ public static function decryptResource($inputHandle, $outputHandle, $key) */ $chunkMAC = \hash_copy($hmac); if ($chunkMAC === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot duplicate a hash context' ); } @@ -585,8 +611,8 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * 3. Did we match? */ - if (!self::hashEquals($finalHMAC, $stored_mac)) { - throw new Ex\InvalidCiphertext( + if (!Core::hashEquals($finalHMAC, $stored_mac)) { + throw new Ex\InvalidCiphertextException( 'Message Authentication failure; tampering detected.' ); } @@ -596,8 +622,8 @@ public static function decryptResource($inputHandle, $outputHandle, $key) /** * 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( + if (\fseek($inputHandle, $config->saltByteSize() + $ivsize + Core::HEADER_VERSION_SIZE, SEEK_SET) === false) { + throw new Ex\CannotPerformOperationException( 'Could not move the input file pointer during decryption' ); } @@ -617,7 +643,7 @@ public static function decryptResource($inputHandle, $outputHandle, $key) */ $pos = \ftell($inputHandle); if ($pos === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Could not get current position in input file during decryption' ); } @@ -626,14 +652,14 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * 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) { + if ($pos + $config->bufferByteSize() >= $cipher_end) { $breakW = true; $read = \fread($inputHandle, $cipher_end - $pos + 1); } else { - $read = \fread($inputHandle, $config['BUFFER']); + $read = \fread($inputHandle, $config->bufferByteSize()); } if ($read === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Could not read input file during decryption' ); } @@ -646,30 +672,30 @@ public static function decryptResource($inputHandle, $outputHandle, $key) \hash_update($hmac2, $read); $calcMAC = \hash_copy($hmac2); if ($calcMAC === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Cannot duplicate a hash context' ); } $calc = \hash_final($calcMAC); if (empty($macs)) { - throw new Ex\InvalidCiphertext( + throw new Ex\InvalidCiphertextException( 'File was modified after MAC verification' ); - } elseif (!self::hashEquals(\array_shift($macs), $calc)) { + } elseif (!Core::hashEquals(\array_shift($macs), $calc)) { throw new Ex\InvalidCiphertextException( 'File was modified after MAC verification' ); } - $thisIv = self::incrementCounter($thisIv, $inc, $config); + $thisIv = Core::incrementCounter($thisIv, $inc, $config); /** * Perform the AES decryption. Decrypts the message. */ $decrypted = \openssl_decrypt( $read, - $config['CIPHER_METHOD'], + $config->cipherMethod(), $ekey, OPENSSL_RAW_DATA, $thisIv @@ -679,7 +705,7 @@ public static function decryptResource($inputHandle, $outputHandle, $key) * Test for decryption faulure */ if ($decrypted === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'OpenSSL decryption error' ); } @@ -690,14 +716,14 @@ public static function decryptResource($inputHandle, $outputHandle, $key) $result = \fwrite( $outputHandle, $decrypted, - self::ourStrlen($decrypted) + Core::ourStrlen($decrypted) ); /** * Check result */ if ($result === false) { - throw new Ex\CannotPerformOperation( + throw new Ex\CannotPerformOperationException( 'Could not write to output file during decryption.' ); } @@ -707,36 +733,70 @@ public static function decryptResource($inputHandle, $outputHandle, $key) } /** - * Take a 4-byte header and get meaningful version information out of it + * Get the encryption configuration based on the version in a header. * - * @param string $header + * @param string $header The header to read the version number from. + * @param string $min_ver_header The header of the minimum version number allowed. + * @return array + * @throws Ex\InvalidCiphertextException */ - protected static function getVersionConfig($header) + private static function getFileVersionConfigFromHeader($header, $min_ver_header) { - $valid = 0; - $valid |= $header[0] ^ "\xDE"; - $valid |= $header[1] ^ "\xF5"; + if (Core::ourSubstr($header, 0, 2) !== Core::ourSubstr(Core::HEADER_MAGIC_FILE, 0, 2)) { + throw new Ex\InvalidCiphertextException( + "Ciphertext file has a bad magic number." + ); + } + $major = \ord($header[2]); $minor = \ord($header[3]); - $config = parent::getCoreVersionConfig($major, $minor, $valid); + $min_major = \ord($min_ver_header[2]); + $min_minor = \ord($min_ver_header[3]); + + if ($major < $min_major || ($major === $min_major && $minor < $min_minor)) { + throw new Ex\InvalidCiphertextException( + "Ciphertext is requesting an insecure fallback." + ); + } + + $config = self::getFileVersionConfigFromMajorMinor($major, $minor); + + return $config; + } + + /** + * + * @param int $major The major version number. + * @param int $minor The minor version number. + * @return array + * @throws Ex\InvalidCiphertextException + */ + private static function getFileVersionConfigFromMajorMinor($major, $minor) + { if ($major === 2) { switch ($minor) { - case 0: - $config['CIPHER_METHOD'] = 'aes-128-ctr'; - $config['BUFFER'] = 1048576; - break; - default: - $valid |= 0xFF; - break; + case 0: + return new FileConfig([ + 'cipher_method' => 'aes-256-ctr', + 'block_byte_size' => 16, + 'key_byte_size' => 32, + 'salt_byte_size' => 16, + 'hash_function_name' => 'sha256', + 'mac_byte_size' => 32, + 'encryption_info_string' => 'DefusePHP|V2File|KeyForEncryption', + 'authentication_info_string' => 'DefusePHP|V2File|KeyForAuthentication', + 'buffer_byte_size' => 1048576 + ]); + default: + throw new Ex\InvalidCiphertextException( + "Unsupported file ciphertext version." + ); } } else { - $valid |= 0xFF; - } - - if ($valid !== 0) { - throw new Ex\InvalidCiphertextException('Unknown ciphertext version'); + throw new Ex\InvalidCiphertextException( + "Unsupported file ciphertext version." + ); } - return $config; } } diff --git a/src/FileConfig.php b/src/FileConfig.php new file mode 100644 index 0000000..5bf98ac --- /dev/null +++ b/src/FileConfig.php @@ -0,0 +1,34 @@ +buffer_byte_size = $config_array["buffer_byte_size"]; + if (!is_int($this->buffer_byte_size) || $this->buffer_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Configuration contains an invalid buffer byte size." + ); + } + + unset($config_array["buffer_byte_size"]); + parent::__construct($config_array); + } + + public function bufferByteSize() + { + return $this->buffer_byte_size; + } +} diff --git a/src/Key.php b/src/Key.php new file mode 100644 index 0000000..3596261 --- /dev/null +++ b/src/Key.php @@ -0,0 +1,255 @@ +key_byte_size = $config_array["key_byte_size"]; + $this->checksum_hash_function = $config_array["checksum_hash_function"]; + $this->checksum_byte_size = $config_array["checksum_byte_size"]; + + if (!\is_int($this->key_byte_size) || $this->key_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Invalid key byte size." + ); + } + + if (\in_array($this->checksum_hash_function, \hash_algos()) === false) { + throw new Ex\CannotPerformOperationException( + "Invalid hash function name." + ); + } + + if (!\is_int($this->checksum_byte_size) || $this->checksum_byte_size <= 0) { + throw new Ex\CannotPerformOperationException( + "Invalid checksum byte size." + ); + } + } + + public function keyByteSize() + { + return $this->key_byte_size; + } + + public function checksumHashFunction() + { + return $this->checksum_hash_function; + } + + public function checksumByteSize() + { + return $this->checksum_byte_size; + } +} + +final class Key +{ + /* We keep the key versioning independent of the ciphertext versioning. */ + const KEY_HEADER_SIZE = 4; + const KEY_MAGIC = "\xDE\xF0"; + const KEY_CURRENT_VERSION = "\xDE\xF0\x00\x00"; + + const MIN_SAFE_KEY_BYTE_SIZE = 16; + + /* + * Format: + * bin2hex([__HEADER__][____KEY BYTES___][___CHECKSUM___]) + * + * HEADER: The 4-byte version header. + * KEY BYTES: The raw key bytes (length may depend on version). + * CHECKSUM: SHA256(HEADER . KEY BYTES). + * + * The checksum field is for detecting accidental corruption *only*. It + * provides no cryptographic functionality. + * + * SECURITY NOTE: + * + * The checksum introduces a potential security weakness. + * + * Suppose an adversary has an exploit against the process containing + * the key that allows them to overwrite an arbitrary byte of memory. + * The adversary has exhausted all options, and can't get remote code + * execution. + * + * If they can overwrite a byte of the key, then force the checksum + * validation to run, then determine (possibly through a side channel) + * whether or not the checksum was correct, they learn whether their + * guess for that byte was correct or not. They can recover the key + * using at most 256 queries per byte. + * + * This attack also applies to authenticated encryption as a whole, in + * the situation where the adversary can overwrite a byte of the key + * and then cause a valid ciphertext to be decrypted, and then + * determine whether the MAC check passed or failed. This is much more + * plausible than attacking encoded keys. + * + * By using the full SHA256 hash instead of truncating it, I'm ensuring + * that both ways of going about the attack are equivalently difficult + * (a shorter checksum might be more useful if the arbitrary write + * is more coarse-grained than a single byte). + */ + + private $key_version_header = null; + private $key_bytes = null; + private $config = null; + + public static function CreateNewRandomKey() + { + $config = self::GetKeyVersionConfigFromKeyHeader(self::KEY_CURRENT_VERSION); + $bytes = Core::secureRandom($config->keyByteSize()); + return new Key(self::KEY_CURRENT_VERSION, $bytes); + } + + public static function LoadFromAsciiSafeString($savedKeyString) + { + try { + $bytes = Encoding::hexToBin($savedKeyString); + } catch (\RangeException $ex) { + throw new Ex\CannotPerformOperationException( + "Key has invalid hex encoding." + ); + } + + /* Make sure we have enough bytes to get the version header. */ + if (Core::ourStrlen($bytes) < self::KEY_HEADER_SIZE) { + throw new Ex\CannotPerformOperationException( + "Saved Key is shorter than the version header." + ); + } + + /* Grab the version header. */ + $version_header = Core::ourSubstr($bytes, 0, self::KEY_HEADER_SIZE); + + /* Grab the config for that version. */ + $config = self::GetKeyVersionConfigFromKeyHeader($version_header); + + /* Now that we know the version, check the length is correct. */ + if (Core::ourStrlen($bytes) !== self::KEY_HEADER_SIZE + + $config->keyByteSize() + + $config->checksumByteSize()) { + throw new Ex\CannotPerformOperationException( + "Saved Key is not the correct size." + ); + } + + /* Grab the bytes that are part of the checksum. */ + $checked_bytes = Core::ourSubstr( + $bytes, + 0, + self::KEY_HEADER_SIZE + $config->keyByteSize() + ); + + /* Grab the included checksum. */ + $checksum_a = Core::ourSubstr( + $bytes, + self::KEY_HEADER_SIZE + $config->keyByteSize(), + $config->checksumByteSize() + ); + + /* Re-compute the checksum. */ + $checksum_b = hash($config->checksumHashFunction(), $checked_bytes, true); + + /* Validate it. It *is* important for this to be constant time. */ + if (!Core::hashEquals($checksum_a, $checksum_b)) { + throw new Ex\CannotPerformOperationException( + "Saved key is corrupted -- checksums don't match." + ); + } + + /* Everything checks out. Grab the key and create a Key object. */ + $key_bytes = Core::ourSubstr($bytes, self::KEY_HEADER_SIZE, $config->keyByteSize()); + return new Key($version_header, $key_bytes); + } + + private function __construct($version_header, $bytes) + { + $this->key_version_header = $version_header; + $this->key_bytes = $bytes; + $this->config = self::GetKeyVersionConfigFromKeyHeader($this->key_version_header); + } + + public function saveToAsciiSafeString() + { + return Encoding::binToHex( + $this->key_version_header . + $this->key_bytes . + hash( + $this->config->checksumHashFunction(), + $this->key_version_header . $this->key_bytes, + true + ) + ); + } + + public function isSafeForCipherTextVersion($major, $minor) + { + /* Legacy decryption uses raw key byte strings, not Key. */ + return $major == 2 && $minor == 0; + } + + public function getRawBytes() + { + if (is_null($this->key_bytes) || Core::ourStrlen($this->key_bytes) < self::MIN_SAFE_KEY_BYTE_SIZE) { + throw new CannotPerformOperationException( + "An attempt was made to use an uninitialzied or too-short key" + ); + } + return $this->key_bytes; + } + + private static function GetKeyVersionConfigFromKeyHeader($key_header) { + if ($key_header === self::KEY_CURRENT_VERSION) { + return new KeyConfig([ + 'key_byte_size' => 32, + 'checksum_hash_function' => 'sha256', + 'checksum_byte_size' => 32 + ]); + } + throw new Ex\CannotPerformOperationException( + "Invalid key version header." + ); + } + + /* + * NEVER use this, exept for testing. + */ + public static function LoadFromRawBytesForTestingPurposesOnlyInsecure($bytes) + { + return new Key(self::KEY_CURRENT_VERSION, $bytes); + } + +} diff --git a/src/RuntimeTests.php b/src/RuntimeTests.php new file mode 100644 index 0000000..770c6a5 --- /dev/null +++ b/src/RuntimeTests.php @@ -0,0 +1,237 @@ +cipherMethod(), \openssl_get_cipher_methods()) === false) { + throw new Ex\CryptoTestFailedException("Cipher method not supported."); + } + + RuntimeTests::AESTestVector($config); + RuntimeTests::HMACTestVector($config); + RuntimeTests::HKDFTestVector($config); + + RuntimeTests::testEncryptDecrypt($config); + if (Core::ourStrlen(Crypto::createNewRandomKey()->getRawBytes()) != $config->keyByteSize()) { + throw new Ex\CryptoTestFailedException(); + } + + if ($config->encryptionInfoString() == $config->authenticationInfoString()) { + throw new Ex\CryptoTestFailedException(); + } + } catch (Ex\CryptoTestFailedException $ex) { + // Do this, otherwise it will stay in the "tests are running" state. + $test_state = 3; + throw $ex; + } + + // Change this to '0' make the tests always re-run (for benchmarking). + $test_state = 1; + } + + private static function testEncryptDecrypt($config) + { + $key = Crypto::createNewRandomKey(); + $data = "EnCrYpT EvErYThInG\x00\x00"; + if (empty($config)) { + $config = Crypto::getVersionConfigFromHeader(Core::CURRENT_VERSION, Core::CURRENT_VERSION); + } + + // Make sure encrypting then decrypting doesn't change the message. + $ciphertext = Crypto::encrypt($data, $key, true); + try { + $decrypted = Crypto::decrypt($ciphertext, $key, true); + } catch (Ex\InvalidCiphertextException $ex) { + // It's important to catch this and change it into a + // Ex\CryptoTestFailedException, otherwise a test failure could trick + // the user into thinking it's just an invalid ciphertext! + throw new Ex\CryptoTestFailedException(); + } + if ($decrypted !== $data) { + throw new Ex\CryptoTestFailedException(); + } + + // Modifying the ciphertext: Appending a string. + try { + Crypto::decrypt($ciphertext . "a", $key, true); + throw new Ex\CryptoTestFailedException(); + } catch (Ex\InvalidCiphertextException $e) { /* expected */ } + + // Modifying the ciphertext: Changing an IV byte. + try { + $ciphertext[4] = chr((ord($ciphertext[4]) + 1) % 256); + Crypto::decrypt($ciphertext, $key, true); + throw new Ex\CryptoTestFailedException(); + } catch (Ex\InvalidCiphertextException $e) { /* expected */ } + + // Decrypting with the wrong key. + $key = Crypto::createNewRandomKey(); + $data = "abcdef"; + $ciphertext = Crypto::encrypt($data, $key, true); + $wrong_key = Crypto::createNewRandomKey(); + try { + Crypto::decrypt($ciphertext, $wrong_key, true); + throw new Ex\CryptoTestFailedException(); + } catch (Ex\InvalidCiphertextException $e) { /* expected */ } + + // Ciphertext too small (shorter than HMAC). + $key = Crypto::createNewRandomKey(); + $ciphertext = \str_repeat("A", $config->macByteSize() - 1); + try { + Crypto::decrypt($ciphertext, $key, true); + throw new Ex\CryptoTestFailedException(); + } catch (Ex\InvalidCiphertextException $e) { /* expected */ } + } + + /** + * Run-time testing + * + * @throws Ex\CryptoTestFailedException + */ + private static function HKDFTestVector($config) + { + // HKDF test vectors from RFC 5869 + if (empty($config)) { + $config = Crypto::getVersionConfigFromHeader(Core::CURRENT_VERSION, Core::CURRENT_VERSION); + } + + // Test Case 1 + $ikm = \str_repeat("\x0b", 22); + $salt = Encoding::hexToBin("000102030405060708090a0b0c"); + $info = Encoding::hexToBin("f0f1f2f3f4f5f6f7f8f9"); + $length = 42; + $okm = Encoding::hexToBin( + "3cb25f25faacd57a90434f64d0362f2a" . + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" . + "34007208d5b887185865" + ); + $computed_okm = Core::HKDF("sha256", $ikm, $length, $info, $salt, $config); + if ($computed_okm !== $okm) { + throw new Ex\CryptoTestFailedException(); + } + + // Test Case 7 + $ikm = \str_repeat("\x0c", 22); + $length = 42; + $okm = Encoding::hexToBin( + "2c91117204d745f3500d636a62f64f0a" . + "b3bae548aa53d423b0d1f27ebba6f5e5" . + "673a081d70cce7acfc48" + ); + $computed_okm = Core::HKDF("sha1", $ikm, $length, '', null, $config); + if ($computed_okm !== $okm) { + throw new Ex\CryptoTestFailedException(); + } + + } + + /** + * Run-Time tests + * + * @throws Ex\CryptoTestFailedException + */ + private static function HMACTestVector($config) + { + if (empty($config)) { + $config = Crypto::getVersionConfigFromHeader(Core::CURRENT_VERSION, Core::CURRENT_VERSION); + } + // HMAC test vector From RFC 4231 (Test Case 1) + $key = \str_repeat("\x0b", 20); + $data = "Hi There"; + $correct = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; + if (\hash_hmac($config->hashFunctionName(), $data, $key) !== $correct) { + throw new Ex\CryptoTestFailedException(); + } + } + + /** + * Run-time tests + * + * @throws Ex\CryptoTestFailedException + */ + private static function AESTestVector($config) + { + // AES CTR mode test vector from NIST SP 800-38A + $key = Encoding::hexToBin( + "603deb1015ca71be2b73aef0857d7781". + "1f352c073b6108d72d9810a30914dff4" + ); + $iv = Encoding::hexToBin("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + $plaintext = Encoding::hexToBin( + "6bc1bee22e409f96e93d7e117393172a" . + "ae2d8a571e03ac9c9eb76fac45af8e51" . + "30c81c46a35ce411e5fbc1191a0a52ef" . + "f69f2445df4f9b17ad2b417be66c3710" + ); + $ciphertext = Encoding::hexToBin( + "601ec313775789a5b7a7f504bbf3d228". + "f443e3ca4d62b59aca84e990cacaf5c5". + "2b0930daa23de94ce87017ba2d84988d". + "dfc9c58db67aada613c2dd08457941a6" + ); + + $config = Crypto::getVersionConfigFromHeader(Core::CURRENT_VERSION, Core::CURRENT_VERSION); + + $computed_ciphertext = Crypto::plainEncrypt($plaintext, $key, $iv, $config); + if ($computed_ciphertext !== $ciphertext) { + echo str_repeat("\n", 30); + var_dump($config); + echo \bin2hex($computed_ciphertext); + echo "\n---\n"; + echo \bin2hex($ciphertext); + echo str_repeat("\n", 30); + throw new Ex\CryptoTestFailedException(); + } + + $computed_plaintext = Crypto::plainDecrypt($ciphertext, $key, $iv, $config); + if ($computed_plaintext !== $plaintext) { + throw new Ex\CryptoTestFailedException(); + } + } + +} diff --git a/test.sh b/test.sh index fd436c5..1a55aa7 100755 --- a/test.sh +++ b/test.sh @@ -1,82 +1,10 @@ #!/bin/bash - -echo "Normal" -echo "--------------------------------------------------" -php -d mbstring.func_overload=0 tests/runtime.php -if [ $? -ne 0 ]; then - echo "FAIL." - exit 1 -fi -echo "--------------------------------------------------" - -echo "" - -echo "Multibyte" -echo "--------------------------------------------------" -php -d mbstring.func_overload=7 tests/runtime.php -if [ $? -ne 0 ]; then - echo "FAIL." - exit 1 -fi -echo "--------------------------------------------------" - -echo "" - -if [ -z "$(php tests/empty.php)" ]; then - echo "PASS: Crypto.php output is empty." -else - echo "FAIL: Crypto.php output is not empty." - exit 1 -fi - -echo "--------------------------------------------------" - -echo "" - -echo "Hex Encoding" -echo "--------------------------------------------------" -php tests/encode.php -if [ $? -ne 0 ]; then - echo "FAIL." - exit 1 -else - echo "PASS: Hex encoding is working correctly" -fi - -echo "--------------------------------------------------" - -echo "" - -echo "Legacy Decryption" -echo "--------------------------------------------------" -php tests/legacy.php -if [ $? -ne 0 ]; then - echo "FAIL." - exit 1 -else - echo "PASS: Legacy decryption is working correctly" -fi - -echo "--------------------------------------------------" - -echo "" - -echo "AES COUNTER MODE" -echo "--------------------------------------------------" -php tests/ctr.php -if [ $? -ne 0 ]; then - echo "FAIL." - exit 1 -else - echo "PASS: Counter incrementing is working correctly" -fi - -echo "--------------------------------------------------" - +set -e +./test/phpunit.sh echo "" ORIGDIR=`pwd` -cd tests/stream +cd test/stream php keygen.php php encrypt.php php decrypt.php -cd $ORIGDIR \ No newline at end of file +cd $ORIGDIR diff --git a/test/phpunit.sh b/test/phpunit.sh new file mode 100755 index 0000000..87e5b68 --- /dev/null +++ b/test/phpunit.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# This was written by Scott Arciszewski. I copied it from his Halite project: +# https://github.com/paragonie/halite + +origdir=`pwd` +cdir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd $origdir +parentdir="$(dirname $cdir)" + +clean=0 # Clean up? +gpg --fingerprint D8406D0D82947747293778314AA394086372C20A +if [ $? -ne 0 ]; then + echo -e "\033[33mDownloading PGP Public Key...\033[0m" + gpg --keyserver pgp.mit.edu --recv-keys D8406D0D82947747293778314AA394086372C20A + # Sebastian Bergmann + gpg --fingerprint D8406D0D82947747293778314AA394086372C20A + if [ $? -ne 0 ]; then + echo -e "\033[31mCould not download PGP public key for verification\033[0m" + exit 1 + fi +fi + +if [ "$clean" -eq 1 ]; then + # Let's clean them up, if they exist + if [ -f phpunit.phar ]; then + rm -f phpunit.phar + fi + if [ -f phpunit.phar.asc ]; then + rm -f phpunit.phar.asc + fi +fi + +# Let's grab the latest release and its signature +if [ ! -f phpunit.phar ]; then + if [[ $PHP_VERSION -ge 50600 ]]; then + wget https://phar.phpunit.de/phpunit.phar + else + wget -O phpunit.phar https://phar.phpunit.de/phpunit-old.phar + fi +fi +if [ ! -f phpunit.phar.asc ]; then + if [[ $PHP_VERSION -ge 50600 ]]; then + wget https://phar.phpunit.de/phpunit.phar.asc + else + wget -O phpunit.phar.asc https://phar.phpunit.de/phpunit-old.phar.asc + fi +fi + +# What are the major/minor versions? +# php -r "var_dump([\Sodium\library_version_major(), \Sodium\library_version_minor()]);" + +# Verify before running +gpg --verify phpunit.phar.asc phpunit.phar +if [ $? -eq 0 ]; then + echo + echo -e "\033[33mBegin Unit Testing\033[0m" + # Run the test suite with normal func_overload. + php -d mbstring.func_overload=0 phpunit.phar --bootstrap "$parentdir/autoload.php" "$parentdir/test/unit" && \ + # Run the test suite again with funky func_overload. + php -d mbstring.func_overload=7 phpunit.phar --bootstrap "$parentdir/autoload.php" "$parentdir/test/unit" + EXITCODE=$? + # Cleanup + if [ "$clean" -eq 1 ]; then + echo -e "\033[32mCleaning Up!\033[0m" + rm -f phpunit.phar + rm -f phpunit.phar.asc + fi + exit $EXITCODE +else + echo + chmod -x phpunit.phar + mv phpunit.phar /tmp/bad-phpunit.phar + mv phpunit.phar.asc /tmp/bad-phpunit.phar.asc + echo -e "\033[31mSignature did not match! Check /tmp/bad-phpunit.phar for trojans\033[0m" + exit 1 +fi diff --git a/tests/stream/decrypt.php b/test/stream/decrypt.php similarity index 95% rename from tests/stream/decrypt.php rename to test/stream/decrypt.php index ebb2d5a..b9c6a4e 100644 --- a/tests/stream/decrypt.php +++ b/test/stream/decrypt.php @@ -8,7 +8,7 @@ $mem = 0; $start_time = $end_time = \microtime(true); -$key = \Defuse\Crypto\Core::hexToBin(\file_get_contents('key.txt')); +$key = \Defuse\Crypto\Encoding::hexToBin(\file_get_contents('key.txt')); echo 'Decrypting', "\n", str_repeat('-', 50), "\n\n"; echo "Load Key:\n\t"; diff --git a/tests/stream/encrypt.php b/test/stream/encrypt.php similarity index 95% rename from tests/stream/encrypt.php rename to test/stream/encrypt.php index 2b80ede..467b994 100644 --- a/tests/stream/encrypt.php +++ b/test/stream/encrypt.php @@ -8,7 +8,7 @@ $mem = 0; $start_time = $end_time = \microtime(true); -$key = \Defuse\Crypto\Core::hexToBin(\file_get_contents('key.txt')); +$key = \Defuse\Crypto\Encoding::hexToBin(\file_get_contents('key.txt')); echo 'Encrypting', "\n", str_repeat('-', 50), "\n\n"; echo "Load Key:\n\t"; diff --git a/tests/stream/error.php b/test/stream/error.php similarity index 100% rename from tests/stream/error.php rename to test/stream/error.php diff --git a/tests/stream/get_large.sh b/test/stream/get_large.sh similarity index 100% rename from tests/stream/get_large.sh rename to test/stream/get_large.sh diff --git a/test/stream/keygen.php b/test/stream/keygen.php new file mode 100644 index 0000000..098f05c --- /dev/null +++ b/test/stream/keygen.php @@ -0,0 +1,6 @@ +getRawBytes())); diff --git a/tests/stream/large.jpg b/test/stream/large.jpg similarity index 100% rename from tests/stream/large.jpg rename to test/stream/large.jpg diff --git a/tests/stream/wat-gigantic-duck.jpg b/test/stream/wat-gigantic-duck.jpg similarity index 100% rename from tests/stream/wat-gigantic-duck.jpg rename to test/stream/wat-gigantic-duck.jpg diff --git a/test/unit/BackwardsCompatibilityTest.php b/test/unit/BackwardsCompatibilityTest.php new file mode 100644 index 0000000..6051a2c --- /dev/null +++ b/test/unit/BackwardsCompatibilityTest.php @@ -0,0 +1,168 @@ +assertEquals( + $end, + \bin2hex($actual_end), + $start . " + " . $inc + ); + } + + public function testFuzzIncrementCounter() + { + $config = new MockConfig; + + /* Test carry propagation. */ + for ($offset = 0; $offset < 16; $offset++) { + /* + * If we start with... + * FF FF FF FF FE FF FF ... FF + * ^- offset + * + * And add 1, we should get... + * + * FF FF FF FF FF 00 00 ... 00 + ^- offset + */ + $start = str_repeat("\xFF", $offset) . "\xFE" . str_repeat("\xFF", 16 - $offset - 1); + $expected_end = str_repeat("\xFF", $offset + 1) . str_repeat("\x00", 16 - $offset - 1); + $actual_end = \Defuse\Crypto\Core::incrementCounter($start, 1, $config); + $this->assertEquals( + \bin2hex($expected_end), + \bin2hex($actual_end), + \bin2hex($start) . " + " . 1 + ); + } + + /* Try using it to add random 24-bit integers, and check the result. */ + for ($trial = 0; $trial < 1000; $trial++) { + $rand_a = mt_rand() & 0x00ffffff; + $rand_b = mt_rand() & 0x00ffffff; + + $prefix = openssl_random_pseudo_bytes(12); + + $start = $prefix . + chr(($rand_a >> 24) & 0xff) . + chr(($rand_a >> 16) & 0xff) . + chr(($rand_a >> 8) & 0xff) . + chr(($rand_a >> 0) & 0xff); + + $sum = $rand_a + $rand_b; + + $expected_end = $prefix . + chr(($sum >> 24) & 0xff) . + chr(($sum >> 16) & 0xff) . + chr(($sum >> 8) & 0xff) . + chr(($sum >> 0) & 0xff); + $actual_end = \Defuse\Crypto\Core::incrementCounter($start, $rand_b, $config); + + $this->assertEquals( + \bin2hex($expected_end), + \bin2hex($actual_end), + \bin2hex($start) . " + " . $rand_b + ); + } + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + */ + public function testIncrementByNegativeValue() + { + $config = new MockConfig; + + \Defuse\Crypto\Core::incrementCounter( + str_repeat("\x00", 16), + -1, + $config + ); + } + + + public function allNonZeroByteValuesProvider() + { + $all_bytes = array(); + for ($i = 1; $i <= 0xff; $i++) { + $all_bytes[] = array($i); + } + return $all_bytes; + } + + /** + * @dataProvider allNonZeroByteValuesProvider + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + */ + public function testIncrementCausingOverflowInFirstByte($lsb) + { + $config = new MockConfig; + + /* Smallest value that will overflow. */ + $increment = (PHP_INT_MAX - $lsb) + 1; + $start = str_repeat("\x00", 15) . chr($lsb); + \Defuse\Crypto\Core::incrementCounter($start, $increment, $config); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + */ + public function testIncrementWithShortIvLength() + { + $config = new MockConfig; + + \Defuse\Crypto\Core::incrementCounter( + str_repeat("\x00", 15), + 1, + $config + ); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + */ + public function testIncrementWithLongIvLength() + { + $config = new MockConfig; + + \Defuse\Crypto\Core::incrementCounter( + str_repeat("\x00", 17), + 1, + $config + ); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + */ + public function testIncrementByNonInteger() + { + $config = new MockConfig; + + \Defuse\Crypto\Core::incrementCounter( + str_repeat("\x00", 16), + 1.0, + $config + ); + } + + public function testCompatibilityWithOpenSSL() + { + $config = new MockConfig; + + /* Plaintext is 0x300 blocks. */ + $plaintext = str_repeat('a', 0x300 * 16); + + /* Start at zero. */ + $starting_nonce = str_repeat("\x00", 16); + + $ciphertext = openssl_encrypt( + $plaintext, + $config->cipherMethod(), + 'YELLOW SUBMARINE', + OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, + $starting_nonce + ); + + /* Take the second half, the last 0x150 blocks. */ + $cipher_lasthalf = mb_substr($ciphertext, 0x150 * 16, 0x150 * 16, '8bit'); + + /* Compute what the nonce should be at the start of the last half. */ + $computed_nonce = \Defuse\Crypto\Core::incrementCounter( + $starting_nonce, + 0x150, + $config + ); + + /* Try to decrypt it using that nonce. */ + $decrypt = openssl_decrypt( + $cipher_lasthalf, + $config->cipherMethod(), + 'YELLOW SUBMARINE', + OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, + $computed_nonce + ); + + /* If it decrypts properly, we computed the nonce the same way. */ + $this->assertEquals( + str_repeat('a', 0x150 * 16), + $decrypt + ); + } +} diff --git a/test/unit/EncodingTest.php b/test/unit/EncodingTest.php new file mode 100644 index 0000000..6ed7015 --- /dev/null +++ b/test/unit/EncodingTest.php @@ -0,0 +1,27 @@ +assertEquals($encode_b, $encode_a); + + $decode_a = Encoding::hexToBin($encode_a); + $decode_b = \hex2bin($encode_b); + + $this->assertEquals($decode_b, $decode_a); + // Just in case. + $this->assertEquals($random, $decode_b); + } + } + } +} diff --git a/test/unit/KeyTest.php b/test/unit/KeyTest.php new file mode 100644 index 0000000..3fc6872 --- /dev/null +++ b/test/unit/KeyTest.php @@ -0,0 +1,64 @@ +assertEquals(32, Core::ourStrlen($key->getRawBytes())); + } + + function testSaveAndLoadKey() + { + $key1 = Key::CreateNewRandomKey(); + $str = $key1->saveToAsciiSafeString(); + $key2 = Key::LoadFromAsciiSafeString($str); + $this->assertEquals($key1->getRawBytes(), $key2->getRawBytes()); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + * @excpectedExceptionMessage key version header + */ + function testIncorrectHeader() + { + $key = Key::CreateNewRandomKey(); + $str = $key->saveToAsciiSafeString(); + $str[0] = 'f'; + Key::LoadFromAsciiSafeString($str); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + * @expectedExceptionMessage checksums don't match + */ + function testIncorrectChecksum() + { + $key = Key::CreateNewRandomKey(); + $str = $key->saveToAsciiSafeString(); + $str[2*Key::KEY_HEADER_SIZE + 0] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 1] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 3] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 4] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 5] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 6] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 7] = 'f'; + $str[2*Key::KEY_HEADER_SIZE + 8] = 'f'; + Key::LoadFromAsciiSafeString($str); + } + + /** + * @expectedException \Defuse\Crypto\Exception\CannotPerformOperationException + * @expectedExceptionMessage invalid hex encoding + */ + function testBadHexEncoding() + { + $key = Key::CreateNewRandomKey(); + $str = $key->saveToAsciiSafeString(); + $str[0] = "Z"; + Key::LoadFromAsciiSafeString($str); + } +} diff --git a/test/unit/LegacyDecryptTest.php b/test/unit/LegacyDecryptTest.php new file mode 100644 index 0000000..cea37cf --- /dev/null +++ b/test/unit/LegacyDecryptTest.php @@ -0,0 +1,45 @@ +assertEquals($plain, 'This is a test message'); + } + + /** + * @expectedException \Defuse\Crypto\Exception\InvalidCiphertextException + */ + function testDecryptLegacyCiphertextWrongKey() + { + $cipher = Encoding::hexToBin( + 'cfdad83ebd506d2c9ada8d48030d0bca'. + '2ff94760e6d39c186adb1290d6c47e35'. + '821e262673c5631c42ebbaf70774d6ef'. + '29aa5eee0e412d646ae380e08189c85d'. + '024b5e2009106870f1db25d8b85fd01f' + ); + + $plain = Crypto::legacyDecrypt( + $cipher, + "\x01\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F" + // ^- I changed that byte + ); + } +} diff --git a/test/unit/RuntimeTestTest.php b/test/unit/RuntimeTestTest.php new file mode 100644 index 0000000..7790323 --- /dev/null +++ b/test/unit/RuntimeTestTest.php @@ -0,0 +1,11 @@ + 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/empty.php b/tests/empty.php deleted file mode 100644 index a7feabe..0000000 --- a/tests/empty.php +++ /dev/null @@ -1,4 +0,0 @@ -