├── LICENSE.md ├── README.md ├── composer.json ├── src ├── AesDecryptingStream.php ├── AesEncryptingStream.php ├── AesGcmDecryptingStream.php ├── AesGcmEncryptingStream.php ├── Base64DecodingStream.php ├── Base64EncodingStream.php ├── Cbc.php ├── CipherMethod.php ├── Ctr.php ├── DecryptionFailedException.php ├── Ecb.php ├── EncryptionFailedException.php ├── HashingStream.php ├── HexDecodingStream.php └── HexEncodingStream.php └── tests ├── AesDecryptingStreamTest.php ├── AesEncryptingStreamTest.php ├── AesEncryptionStreamTestTrait.php ├── AesGcmDecryptingStreamTest.php ├── AesGcmEncryptingStreamTest.php ├── Base64DecodingStreamTest.php ├── Base64EncodingStreamTest.php ├── CbcTest.php ├── CtrTest.php ├── EcbTest.php ├── HashingStreamTest.php ├── HexDecodingStreamTest.php ├── HexEncodingStreamTest.php └── RandomByteStream.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Apache License 2 | Version 2.0, January 2004 3 | 4 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 5 | 6 | ## 1. Definitions. 7 | 8 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 9 | through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the 12 | License. 13 | 14 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled 15 | by, or are under common control with that entity. For the purposes of this definition, "control" means 16 | (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract 17 | or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 18 | ownership of such entity. 19 | 20 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 21 | 22 | "Source" form shall mean the preferred form for making modifications, including but not limited to software 23 | source code, documentation source, and configuration files. 24 | 25 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, 26 | including but not limited to compiled object code, generated documentation, and conversions to other media 27 | types. 28 | 29 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, 30 | as indicated by a copyright notice that is included in or attached to the work (an example is provided in the 31 | Appendix below). 32 | 33 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) 34 | the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, 35 | as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not 36 | include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work 37 | and Derivative Works thereof. 38 | 39 | "Contribution" shall mean any work of authorship, including the original version of the Work and any 40 | modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to 41 | Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to 42 | submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of 43 | electronic, verbal, or written communication sent to the Licensor or its representatives, including but not 44 | limited to communication on electronic mailing lists, source code control systems, and issue tracking systems 45 | that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but 46 | excluding communication that is conspicuously marked or otherwise designated in writing by the copyright 47 | owner as "Not a Contribution." 48 | 49 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been 50 | received by Licensor and subsequently incorporated within the Work. 51 | 52 | ## 2. Grant of Copyright License. 53 | 54 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, 55 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 56 | Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such 57 | Derivative Works in Source or Object form. 58 | 59 | ## 3. Grant of Patent License. 60 | 61 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, 62 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent 63 | license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such 64 | license applies only to those patent claims licensable by such Contributor that are necessarily infringed by 65 | their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such 66 | Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim 67 | or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work 68 | constitutes direct or contributory patent infringement, then any patent licenses granted to You under this 69 | License for that Work shall terminate as of the date such litigation is filed. 70 | 71 | ## 4. Redistribution. 72 | 73 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without 74 | modifications, and in Source or Object form, provided that You meet the following conditions: 75 | 76 | 1. You must give any other recipients of the Work or Derivative Works a copy of this License; and 77 | 78 | 2. You must cause any modified files to carry prominent notices stating that You changed the files; and 79 | 80 | 3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, 81 | trademark, and attribution notices from the Source form of the Work, excluding those notices that do 82 | not pertain to any part of the Derivative Works; and 83 | 84 | 4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that 85 | You distribute must include a readable copy of the attribution notices contained within such NOTICE 86 | file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one 87 | of the following places: within a NOTICE text file distributed as part of the Derivative Works; within 88 | the Source form or documentation, if provided along with the Derivative Works; or, within a display 89 | generated by the Derivative Works, if and wherever such third-party notices normally appear. The 90 | contents of the NOTICE file are for informational purposes only and do not modify the License. You may 91 | add Your own attribution notices within Derivative Works that You distribute, alongside or as an 92 | addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be 93 | construed as modifying the License. 94 | 95 | You may add Your own copyright statement to Your modifications and may provide additional or different license 96 | terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative 97 | Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the 98 | conditions stated in this License. 99 | 100 | ## 5. Submission of Contributions. 101 | 102 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by 103 | You to the Licensor shall be under the terms and conditions of this License, without any additional terms or 104 | conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate 105 | license agreement you may have executed with Licensor regarding such Contributions. 106 | 107 | ## 6. Trademarks. 108 | 109 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of 110 | the Licensor, except as required for reasonable and customary use in describing the origin of the Work and 111 | reproducing the content of the NOTICE file. 112 | 113 | ## 7. Disclaimer of Warranty. 114 | 115 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor 116 | provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 117 | or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 118 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the 119 | appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of 120 | permissions under this License. 121 | 122 | ## 8. Limitation of Liability. 123 | 124 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless 125 | required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any 126 | Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential 127 | damages of any character arising as a result of this License or out of the use or inability to use the Work 128 | (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or 129 | any and all other commercial damages or losses), even if such Contributor has been advised of the possibility 130 | of such damages. 131 | 132 | ## 9. Accepting Warranty or Additional Liability. 133 | 134 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, 135 | acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this 136 | License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole 137 | responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold 138 | each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason 139 | of your accepting any such warranty or additional liability. 140 | 141 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PSR-7 Stream Encryption Decorators 2 | 3 | [![Build Status](https://travis-ci.org/jeskew/php-encrypted-streams.svg?branch=master)](https://travis-ci.org/jeskew/php-encrypted-streams) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/jsq/psr7-stream-encryption.svg?style=flat)](https://packagist.org/packages/jsq/psr7-stream-encryption) 5 | [![Author](http://img.shields.io/badge/author-@jreskew-blue.svg?style=flat-square)](https://twitter.com/jreskew) 6 | 7 | PHP's built-in OpenSSL bindings provide a convenient means of encrypting and 8 | decrypting data. The interface provided by `ext-openssl`, however, only operates 9 | on strings, so decrypting a large ciphertext would require loading the entire 10 | ciphertext into memory and receiving a string containing the entirety of the 11 | decoded plaintext. 12 | 13 | This package aims to allow the encryption and decryption of streams of arbitrary 14 | size. It supports streaming encryption and decryption using AES-CBC, AES-CTR, 15 | and AES-ECB. 16 | 17 | > Using AES-ECB is **NOT RECOMMENDED** for new systems. It is included to allow 18 | interoperability with older systems. Please consult [Wikipedia](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_Codebook_.28ECB.29) 19 | for a discussion of the drawbacks of ECB. 20 | 21 | ## Usage 22 | 23 | Decorate an instance of `Psr\Http\Message\StreamInterface` with an encrypting 24 | decorator to incrementally encrypt the contents of the decorated stream as 25 | `read` is called on the decorating stream: 26 | 27 | ```php 28 | $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 29 | $cipherMethod = new Cbc($iv); 30 | $key = 'some-secret-password-here'; 31 | 32 | $inStream = new Stream(fopen('some-input-file', 'r')); // Any PSR-7 stream will be fine here 33 | $cipherTextStream = new AesEncryptingStream($inStream, $key, $cipherMethod); // Wrap the stream in an EncryptingStream 34 | $cipherTextFile = Psr7\stream_for(fopen('encrypted.file', 'w')); 35 | Psr7\copy_to_stream($cipherTextStream, $cipherTextFile); // When you read from the encrypting stream, the data will be encrypted. 36 | 37 | // You'll also need to store the IV somewhere, because we'll need it later to decrypt the data. 38 | // In this case, I'll base64 encode it and stick it in a file (but we could put it anywhere where we can retrieve it later, like a database column) 39 | file_put_contents('encrypted.iv', base64_encode($iv)); 40 | ``` 41 | 42 | No encryption is performed until `read` is called on the encrypting stream. 43 | 44 | To calculate the HMAC of a cipher text, wrap a decorated stream with an instance 45 | of `HashingStream`: 46 | 47 | ```php 48 | $hash = null; 49 | $ciphertext = new Jsq\EncryptionStreams\AesEncryptingStream( 50 | $plaintext, 51 | $key, 52 | $cipherMethod 53 | ); 54 | $hashingDecorator = new Jsq\EncryptionStreams\HashingStream( 55 | $ciphertext, 56 | $key, 57 | function ($calculatedHash) use (&$hash) { 58 | $hash = $calculatedHash; 59 | } 60 | ); 61 | 62 | while (!$ciphertext->eof()) { 63 | $ciphertext->read(1024 * 1024); 64 | } 65 | 66 | assert('$hash === $hashingDecorator->getHash()'); 67 | ``` 68 | 69 | When decrypting a cipher text, wrap the cipher text in a hasing decorator before 70 | passing it as an argument to the decrypting stream: 71 | 72 | ```php 73 | $key = 'secret key'; 74 | $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 75 | $plainText = 'Super secret text'; 76 | $cipherText = openssl_encrypt( 77 | $plainText, 78 | 'aes-256-cbc', 79 | $key, 80 | OPENSSL_RAW_DATA 81 | $iv 82 | ); 83 | $expectedHash = hash('sha256', $cipherText); 84 | 85 | $hashingDecorator = new Jsq\EncryptingStreams\HashingStream( 86 | GuzzleHttp\Psr7\stream_for($cipherText), 87 | $key, 88 | function ($hash) use ($expectedHash) { 89 | if ($hash !== $expectedHash) { 90 | throw new DomainException('Cipher text mac does not match expected value!'); 91 | } 92 | } 93 | ); 94 | 95 | $decrypted = new Jsq\EncryptionStreams\AesEncryptingStream( 96 | $cipherText, 97 | $key, 98 | $cipherMethod 99 | ); 100 | while (!$decrypted->eof()) { 101 | $decrypted->read(1024 * 1024); 102 | } 103 | ``` 104 | 105 | As with the encrypting decorators, `HashingStream`s are lazy and will only hash 106 | the underlying stream as it is read. In the example above, no exception would be 107 | thrown until the entire cipher text had been read (and all but the last block 108 | deciphered). 109 | 110 | `HashingStream`s are not seekable, so you will need to wrap on in a 111 | `GuzzleHttp\Psr7\CachingStream` to support random access. 112 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsq/psr7-stream-encryption", 3 | "description": "For encrypting and decrypting streams of arbitrary size.", 4 | "minimum-stability": "stable", 5 | "keywords": ["psr","psr-7","stream","encryption"], 6 | "type": "library", 7 | "license": "Apache-2.0", 8 | "authors": [ 9 | { 10 | "name": "Jonathan Eskew", 11 | "email": "jonathan@jeskew.net" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "Jsq\\EncryptionStreams\\": "src/" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "Jsq\\EncryptionStreams\\": "tests/" 22 | } 23 | }, 24 | "require": { 25 | "php": ">=7.1", 26 | "ext-openssl": "*", 27 | "guzzlehttp/psr7": "~1.0", 28 | "psr/http-message": "~1.0" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^6.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/AesDecryptingStream.php: -------------------------------------------------------------------------------- 1 | stream = $cipherText; 45 | $this->key = $key; 46 | $this->cipherMethod = clone $cipherMethod; 47 | } 48 | 49 | public function eof() 50 | { 51 | return $this->cipherBuffer === '' && $this->stream->eof(); 52 | } 53 | 54 | public function getSize(): ?int 55 | { 56 | $plainTextSize = $this->stream->getSize(); 57 | 58 | if ($this->cipherMethod->requiresPadding()) { 59 | // PKCS7 padding requires that between 1 and self::BLOCK_SIZE be 60 | // added to the plaintext to make it an even number of blocks. The 61 | // plaintext is between strlen($cipherText) - self::BLOCK_SIZE and 62 | // strlen($cipherText) - 1 63 | return null; 64 | } 65 | 66 | return $plainTextSize; 67 | } 68 | 69 | public function isWritable(): bool 70 | { 71 | return false; 72 | } 73 | 74 | public function read($length): string 75 | { 76 | if ($length > strlen($this->plainBuffer)) { 77 | $this->plainBuffer .= $this->decryptBlock( 78 | self::BLOCK_SIZE * ceil(($length - strlen($this->plainBuffer)) / self::BLOCK_SIZE) 79 | ); 80 | } 81 | 82 | $data = substr($this->plainBuffer, 0, $length); 83 | $this->plainBuffer = substr($this->plainBuffer, $length); 84 | 85 | return $data ? $data : ''; 86 | } 87 | 88 | public function seek($offset, $whence = SEEK_SET): void 89 | { 90 | if ($offset === 0 && $whence === SEEK_SET) { 91 | $this->plainBuffer = ''; 92 | $this->cipherBuffer = ''; 93 | $this->cipherMethod->seek(0, SEEK_SET); 94 | $this->stream->seek(0, SEEK_SET); 95 | } else { 96 | throw new LogicException('AES encryption streams only support being' 97 | . ' rewound, not arbitrary seeking.'); 98 | } 99 | } 100 | 101 | private function decryptBlock(int $length): string 102 | { 103 | if ($this->cipherBuffer === '' && $this->stream->eof()) { 104 | return ''; 105 | } 106 | 107 | $cipherText = $this->cipherBuffer; 108 | while (strlen($cipherText) < $length && !$this->stream->eof()) { 109 | $cipherText .= $this->stream->read($length - strlen($cipherText)); 110 | } 111 | 112 | $options = OPENSSL_RAW_DATA; 113 | $this->cipherBuffer = $this->stream->read(self::BLOCK_SIZE); 114 | if (!($this->cipherBuffer === '' && $this->stream->eof())) { 115 | $options |= OPENSSL_ZERO_PADDING; 116 | } 117 | 118 | $plaintext = openssl_decrypt( 119 | $cipherText, 120 | $this->cipherMethod->getOpenSslName(), 121 | $this->key, 122 | $options, 123 | $this->cipherMethod->getCurrentIv() 124 | ); 125 | 126 | if ($plaintext === false) { 127 | throw new DecryptionFailedException("Unable to decrypt $cipherText with an initialization vector" 128 | . " of {$this->cipherMethod->getCurrentIv()} using the {$this->cipherMethod->getOpenSslName()}" 129 | . " algorithm. Please ensure you have provided the correct algorithm, initialization vector, and key."); 130 | } 131 | 132 | $this->cipherMethod->update($cipherText); 133 | 134 | return $plaintext; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/AesEncryptingStream.php: -------------------------------------------------------------------------------- 1 | stream = $plainText; 40 | $this->key = $key; 41 | $this->cipherMethod = clone $cipherMethod; 42 | } 43 | 44 | public function getSize(): ?int 45 | { 46 | $plainTextSize = $this->stream->getSize(); 47 | 48 | if ($this->cipherMethod->requiresPadding() && $plainTextSize !== null) { 49 | // PKCS7 padding requires that between 1 and self::BLOCK_SIZE be 50 | // added to the plaintext to make it an even number of blocks. 51 | $padding = self::BLOCK_SIZE - $plainTextSize % self::BLOCK_SIZE; 52 | return $plainTextSize + $padding; 53 | } 54 | 55 | return $plainTextSize; 56 | } 57 | 58 | public function isWritable(): bool 59 | { 60 | return false; 61 | } 62 | 63 | public function read($length): string 64 | { 65 | if ($length > strlen($this->buffer)) { 66 | $this->buffer .= $this->encryptBlock( 67 | self::BLOCK_SIZE * ceil(($length - strlen($this->buffer)) / self::BLOCK_SIZE) 68 | ); 69 | } 70 | 71 | $data = substr($this->buffer, 0, $length); 72 | $this->buffer = substr($this->buffer, $length); 73 | 74 | return $data ? $data : ''; 75 | } 76 | 77 | public function seek($offset, $whence = SEEK_SET): void 78 | { 79 | if ($whence === SEEK_CUR) { 80 | $offset = $this->tell() + $offset; 81 | $whence = SEEK_SET; 82 | } 83 | 84 | if ($whence === SEEK_SET) { 85 | $this->buffer = ''; 86 | $wholeBlockOffset 87 | = (int) ($offset / self::BLOCK_SIZE) * self::BLOCK_SIZE; 88 | $this->stream->seek($wholeBlockOffset); 89 | $this->cipherMethod->seek($wholeBlockOffset); 90 | $this->read($offset - $wholeBlockOffset); 91 | } else { 92 | throw new LogicException('Unrecognized whence.'); 93 | } 94 | } 95 | 96 | private function encryptBlock(int $length): string 97 | { 98 | if ($this->stream->eof()) { 99 | return ''; 100 | } 101 | 102 | $plainText = ''; 103 | do { 104 | $plainText .= $this->stream->read($length - strlen($plainText)); 105 | } while (strlen($plainText) < $length && !$this->stream->eof()); 106 | 107 | $options = OPENSSL_RAW_DATA; 108 | if (!$this->stream->eof()) { 109 | $options |= OPENSSL_ZERO_PADDING; 110 | } 111 | 112 | $cipherText = openssl_encrypt( 113 | $plainText, 114 | $this->cipherMethod->getOpenSslName(), 115 | $this->key, 116 | $options, 117 | $this->cipherMethod->getCurrentIv() 118 | ); 119 | 120 | if ($cipherText === false) { 121 | throw new EncryptionFailedException("Unable to encrypt data with an initialization vector" 122 | . " of {$this->cipherMethod->getCurrentIv()} using the {$this->cipherMethod->getOpenSslName()}" 123 | . " algorithm. Please ensure you have provided a valid algorithm and initialization vector."); 124 | } 125 | 126 | $this->cipherMethod->update($cipherText); 127 | 128 | return $cipherText; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/AesGcmDecryptingStream.php: -------------------------------------------------------------------------------- 1 | cipherText = $cipherText; 36 | $this->key = $key; 37 | $this->initializationVector = $initializationVector; 38 | $this->tag = $tag; 39 | $this->aad = $aad; 40 | $this->tagLength = $tagLength; 41 | $this->keySize = $keySize; 42 | } 43 | 44 | public function createStream(): StreamInterface 45 | { 46 | $plaintext = openssl_decrypt( 47 | (string) $this->cipherText, 48 | "aes-{$this->keySize}-gcm", 49 | $this->key, 50 | OPENSSL_RAW_DATA, 51 | $this->initializationVector, 52 | $this->tag, 53 | $this->aad 54 | ); 55 | 56 | if ($plaintext === false) { 57 | throw new DecryptionFailedException("Unable to decrypt data with an initialization vector" 58 | . " of {$this->initializationVector} using the aes-{$this->keySize}-gcm algorithm. Please" 59 | . " ensure you have provided a valid key size, initialization vector, and key."); 60 | } 61 | 62 | return Psr7\stream_for($plaintext); 63 | } 64 | 65 | public function isWritable(): bool 66 | { 67 | return false; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/AesGcmEncryptingStream.php: -------------------------------------------------------------------------------- 1 | plaintext = $plaintext; 35 | $this->key = $key; 36 | $this->initializationVector = $initializationVector; 37 | $this->aad = $aad; 38 | $this->tagLength = $tagLength; 39 | $this->keySize = $keySize; 40 | } 41 | 42 | public function createStream(): StreamInterface 43 | { 44 | $cipherText = openssl_encrypt( 45 | (string) $this->plaintext, 46 | "aes-{$this->keySize}-gcm", 47 | $this->key, 48 | OPENSSL_RAW_DATA, 49 | $this->initializationVector, 50 | $this->tag, 51 | $this->aad, 52 | $this->tagLength 53 | ); 54 | 55 | if ($cipherText === false) { 56 | throw new EncryptionFailedException("Unable to encrypt data with an initialization vector" 57 | . " of {$this->initializationVector} using the aes-{$this->keySize}-gcm algorithm. Please" 58 | . " ensure you have provided a valid key size and initialization vector."); 59 | } 60 | 61 | return Psr7\stream_for($cipherText); 62 | } 63 | 64 | public function getTag(): string 65 | { 66 | return $this->tag; 67 | } 68 | 69 | public function isWritable(): bool 70 | { 71 | return false; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Base64DecodingStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 24 | } 25 | 26 | public function getSize(): ?int 27 | { 28 | return null; 29 | } 30 | 31 | public function read($length): string 32 | { 33 | $toRead = ceil($length / 3) * 4; 34 | $this->buffer .= base64_decode($this->stream->read($toRead)); 35 | 36 | $toReturn = substr($this->buffer, 0, $length); 37 | $this->buffer = substr($this->buffer, $length); 38 | return $toReturn; 39 | } 40 | } -------------------------------------------------------------------------------- /src/Base64EncodingStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 24 | } 25 | 26 | public function getSize(): ?int 27 | { 28 | $unencodedSize = $this->stream->getSize(); 29 | return $unencodedSize === null 30 | ? null 31 | : (int) ceil($unencodedSize / 3) * 4; 32 | } 33 | 34 | public function read($length): string 35 | { 36 | $toRead = ceil($length / 4) * 3; 37 | $this->buffer .= base64_encode($this->stream->read($toRead)); 38 | 39 | $toReturn = substr($this->buffer, 0, $length); 40 | $this->buffer = substr($this->buffer, $length); 41 | return $toReturn; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Cbc.php: -------------------------------------------------------------------------------- 1 | baseIv = $this->iv = $iv; 29 | $this->keySize = $keySize; 30 | 31 | if (strlen($iv) !== openssl_cipher_iv_length($this->getOpenSslName())) { 32 | throw new Iae('Invalid initialization vector'); 33 | } 34 | } 35 | 36 | public function getOpenSslName(): string 37 | { 38 | return "aes-{$this->keySize}-cbc"; 39 | } 40 | 41 | public function getCurrentIv(): string 42 | { 43 | return $this->iv; 44 | } 45 | 46 | public function requiresPadding(): bool 47 | { 48 | return true; 49 | } 50 | 51 | public function seek(int $offset, int $whence = SEEK_SET): void 52 | { 53 | if ($offset === 0 && $whence === SEEK_SET) { 54 | $this->iv = $this->baseIv; 55 | } else { 56 | throw new LogicException('CBC initialization only support being' 57 | . ' rewound, not arbitrary seeking.'); 58 | } 59 | } 60 | 61 | public function update(string $cipherTextBlock): void 62 | { 63 | $this->iv = substr($cipherTextBlock, self::BLOCK_SIZE * -1); 64 | } 65 | } -------------------------------------------------------------------------------- /src/CipherMethod.php: -------------------------------------------------------------------------------- 1 | keySize = $keySize; 32 | if (strlen($iv) !== openssl_cipher_iv_length($this->getOpenSslName())) { 33 | throw new InvalidArgumentException('Invalid initialization vector'); 34 | } 35 | 36 | $this->iv = $this->extractIvParts($iv); 37 | $this->resetOffset(); 38 | } 39 | 40 | public function getOpenSslName(): string 41 | { 42 | return "aes-{$this->keySize}-ctr"; 43 | } 44 | 45 | public function getCurrentIv(): string 46 | { 47 | return $this->calculateCurrentIv($this->iv, $this->ctrOffset); 48 | } 49 | 50 | public function requiresPadding(): bool 51 | { 52 | return false; 53 | } 54 | 55 | public function seek(int $offset, int $whence = SEEK_SET): void 56 | { 57 | if ($offset % self::BLOCK_SIZE !== 0) { 58 | throw new LogicException('CTR initialization vectors only support ' 59 | . ' seeking to indexes that are multiples of ' 60 | . self::BLOCK_SIZE); 61 | } 62 | 63 | if ($whence === SEEK_SET) { 64 | $this->resetOffset(); 65 | $this->incrementOffset($offset / self::BLOCK_SIZE); 66 | } elseif ($whence === SEEK_CUR) { 67 | if ($offset < 0) { 68 | throw new LogicException('Negative offsets are not supported.'); 69 | } 70 | 71 | $this->incrementOffset($offset / self::BLOCK_SIZE); 72 | } else { 73 | throw new LogicException('Unrecognized whence.'); 74 | } 75 | } 76 | 77 | public function update(string $cipherTextBlock): void 78 | { 79 | $this->incrementOffset(strlen($cipherTextBlock) / self::BLOCK_SIZE); 80 | } 81 | 82 | /** 83 | * @param string $iv 84 | * @return int[] 85 | */ 86 | private function extractIvParts(string $iv): array 87 | { 88 | return array_map(function ($part) { 89 | return unpack('nnum', $part)['num']; 90 | }, str_split($iv, 2)); 91 | } 92 | 93 | /** 94 | * @param int[] $baseIv 95 | * @param int[] $ctrOffset 96 | * @return string 97 | */ 98 | private function calculateCurrentIv(array $baseIv, array $ctrOffset): string 99 | { 100 | $iv = array_fill(0, 8, 0); 101 | $carry = 0; 102 | for ($i = 7; $i >= 0; $i--) { 103 | $sum = $ctrOffset[$i] + $baseIv[$i] + $carry; 104 | $carry = (int) ($sum / self::CTR_BLOCK_MAX); 105 | $iv[$i] = $sum % self::CTR_BLOCK_MAX; 106 | } 107 | 108 | return implode(array_map(function ($ivBlock) { 109 | return pack('n', $ivBlock); 110 | }, $iv)); 111 | } 112 | 113 | private function incrementOffset(int $incrementBy): void 114 | { 115 | for ($i = 7; $i >= 0; $i--) { 116 | $incrementedBlock = $this->ctrOffset[$i] + $incrementBy; 117 | $incrementBy = (int) ($incrementedBlock / self::CTR_BLOCK_MAX); 118 | $this->ctrOffset[$i] = $incrementedBlock % self::CTR_BLOCK_MAX; 119 | } 120 | } 121 | 122 | private function resetOffset(): void 123 | { 124 | $this->ctrOffset = array_fill(0, 8, 0); 125 | } 126 | } -------------------------------------------------------------------------------- /src/DecryptionFailedException.php: -------------------------------------------------------------------------------- 1 | keySize = $keySize; 17 | } 18 | 19 | public function getOpenSslName(): string 20 | { 21 | return "aes-{$this->keySize}-ecb"; 22 | } 23 | 24 | public function getCurrentIv(): string 25 | { 26 | return ''; 27 | } 28 | 29 | public function requiresPadding(): bool 30 | { 31 | return true; 32 | } 33 | 34 | public function seek(int $offset, int $whence = SEEK_SET): void {} 35 | 36 | public function update(string $cipherTextBlock): void {} 37 | } -------------------------------------------------------------------------------- /src/EncryptionFailedException.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 44 | $this->key = $key; 45 | $this->onComplete = $onComplete; 46 | $this->algorithm = $algorithm; 47 | 48 | $this->initializeHash(); 49 | } 50 | 51 | /** 52 | * Returns the raw binary hash of the wrapped stream if it has been read. 53 | * Returns null otherwise. 54 | */ 55 | public function getHash(): ?string 56 | { 57 | return $this->hash; 58 | } 59 | 60 | public function read($length): string 61 | { 62 | $read = $this->stream->read($length); 63 | if (strlen($read) > 0) { 64 | hash_update($this->hashResource, $read); 65 | } 66 | if ($this->stream->eof()) { 67 | $this->hash = hash_final($this->hashResource, true); 68 | if ($this->onComplete) { 69 | call_user_func($this->onComplete, $this->hash); 70 | } 71 | } 72 | 73 | return $read; 74 | } 75 | 76 | public function seek($offset, $whence = SEEK_SET): void 77 | { 78 | if ($offset === 0 && $whence === SEEK_SET) { 79 | $this->stream->seek($offset, $whence); 80 | $this->initializeHash(); 81 | } else { 82 | throw new LogicException('AES encryption streams only support being' 83 | . ' rewound, not arbitrary seeking.'); 84 | } 85 | } 86 | 87 | private function initializeHash(): void 88 | { 89 | $this->hash = null; 90 | $this->hashResource = hash_init( 91 | $this->algorithm, 92 | $this->key !== null ? HASH_HMAC : 0, 93 | $this->key 94 | ); 95 | } 96 | } -------------------------------------------------------------------------------- /src/HexDecodingStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 24 | } 25 | 26 | public function getSize(): ?int 27 | { 28 | $unencodedSize = $this->stream->getSize(); 29 | return $unencodedSize === null 30 | ? null 31 | : intval($unencodedSize / 2); 32 | } 33 | 34 | public function read($length): string 35 | { 36 | $this->buffer .= hex2bin($this->stream->read($length * 2)); 37 | 38 | $toReturn = substr($this->buffer, 0, $length); 39 | $this->buffer = substr($this->buffer, $length); 40 | return $toReturn; 41 | } 42 | } -------------------------------------------------------------------------------- /src/HexEncodingStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 24 | } 25 | 26 | public function getSize(): ?int 27 | { 28 | $unencodedSize = $this->stream->getSize(); 29 | return $unencodedSize === null 30 | ? null 31 | : $unencodedSize * 2; 32 | } 33 | 34 | public function read($length): string 35 | { 36 | $this->buffer .= bin2hex($this->stream->read(ceil($length / 2))); 37 | 38 | $toReturn = substr($this->buffer, 0, $length); 39 | $this->buffer = substr($this->buffer, $length); 40 | return $toReturn; 41 | } 42 | } -------------------------------------------------------------------------------- /tests/AesDecryptingStreamTest.php: -------------------------------------------------------------------------------- 1 | getOpenSslName(), 31 | self::KEY, 32 | OPENSSL_RAW_DATA, 33 | $iv->getCurrentIv() 34 | ); 35 | 36 | $this->assertSame( 37 | (string) new AesDecryptingStream(Psr7\stream_for($cipherText), self::KEY, $iv), 38 | $plainText 39 | ); 40 | } 41 | 42 | /** 43 | * @dataProvider cartesianJoinInputCipherMethodProvider 44 | * 45 | * @param StreamInterface $plainTextStream 46 | * @param string $plainText 47 | * @param CipherMethod $iv 48 | */ 49 | public function testReportsSizeOfPlaintextWherePossible( 50 | StreamInterface $plainTextStream, 51 | string $plainText, 52 | CipherMethod $iv 53 | ) { 54 | $cipherText = openssl_encrypt( 55 | $plainText, 56 | $iv->getOpenSslName(), 57 | self::KEY, 58 | OPENSSL_RAW_DATA, 59 | $iv->getCurrentIv() 60 | ); 61 | $deciphered = new AesDecryptingStream( 62 | Psr7\stream_for($cipherText), 63 | self::KEY, 64 | $iv 65 | ); 66 | 67 | if ($iv->requiresPadding()) { 68 | $this->assertNull($deciphered->getSize()); 69 | } else { 70 | $this->assertSame(strlen($plainText), $deciphered->getSize()); 71 | } 72 | } 73 | 74 | /** 75 | * @dataProvider cartesianJoinInputCipherMethodProvider 76 | * 77 | * @param StreamInterface $plainTextStream 78 | * @param string $plainText 79 | * @param CipherMethod $iv 80 | */ 81 | public function testSupportsReadingBeyondTheEndOfTheStream( 82 | StreamInterface $plainTextStream, 83 | string $plainText, 84 | CipherMethod $iv 85 | ) { 86 | $cipherText = openssl_encrypt( 87 | $plainText, 88 | $iv->getOpenSslName(), 89 | self::KEY, 90 | OPENSSL_RAW_DATA, 91 | $iv->getCurrentIv() 92 | ); 93 | $deciphered = new AesDecryptingStream(Psr7\stream_for($cipherText), self::KEY, $iv); 94 | $read = $deciphered->read(strlen($plainText) + AesDecryptingStream::BLOCK_SIZE); 95 | $this->assertSame($plainText, $read); 96 | } 97 | 98 | /** 99 | * @dataProvider cartesianJoinInputCipherMethodProvider 100 | * 101 | * @param StreamInterface $plainTextStream 102 | * @param string $plainText 103 | * @param CipherMethod $iv 104 | */ 105 | public function testSupportsRewinding( 106 | StreamInterface $plainTextStream, 107 | string $plainText, 108 | CipherMethod $iv 109 | ) { 110 | $cipherText = openssl_encrypt( 111 | $plainText, 112 | $iv->getOpenSslName(), 113 | self::KEY, 114 | OPENSSL_RAW_DATA, 115 | $iv->getCurrentIv() 116 | ); 117 | $deciphered = new AesDecryptingStream(Psr7\stream_for($cipherText), self::KEY, $iv); 118 | $firstBytes = $deciphered->read(256 * 2 + 3); 119 | $deciphered->rewind(); 120 | $this->assertSame($firstBytes, $deciphered->read(256 * 2 + 3)); 121 | } 122 | 123 | /** 124 | * @dataProvider cipherMethodProvider 125 | * 126 | * @param CipherMethod $iv 127 | */ 128 | public function testMemoryUsageRemainsConstant(CipherMethod $iv) 129 | { 130 | $memory = memory_get_usage(); 131 | 132 | $cipherStream = new AesEncryptingStream(new RandomByteStream(124 * self::MB), self::KEY, clone $iv); 133 | $stream = new AesDecryptingStream($cipherStream, self::KEY, clone $iv); 134 | 135 | while (!$stream->eof()) { 136 | $stream->read(self::MB); 137 | } 138 | 139 | // Reading 1MB chunks should take 2MB 140 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 141 | } 142 | 143 | public function testIsNotWritable() 144 | { 145 | $stream = new AesDecryptingStream( 146 | new RandomByteStream(124 * self::MB), 147 | 'foo', 148 | new Cbc(random_bytes(openssl_cipher_iv_length('aes-256-cbc'))) 149 | ); 150 | 151 | $this->assertFalse($stream->isWritable()); 152 | } 153 | 154 | /** 155 | * @expectedException \LogicException 156 | */ 157 | public function testDoesNotSupportArbitrarySeeking() 158 | { 159 | $stream = new AesDecryptingStream( 160 | new RandomByteStream(124 * self::MB), 161 | 'foo', 162 | new Cbc(random_bytes(openssl_cipher_iv_length('aes-256-cbc'))) 163 | ); 164 | 165 | $stream->seek(1); 166 | } 167 | 168 | /** 169 | * @dataProvider cipherMethodProvider 170 | * 171 | * @param CipherMethod $cipherMethod 172 | */ 173 | public function testReturnsEmptyStringWhenSourceStreamEmpty( 174 | CipherMethod $cipherMethod 175 | ) { 176 | $stream = new AesDecryptingStream( 177 | new AesEncryptingStream(Psr7\stream_for(''), self::KEY, clone $cipherMethod), 178 | self::KEY, 179 | $cipherMethod 180 | ); 181 | 182 | $this->assertEmpty($stream->read(self::MB)); 183 | $this->assertSame($stream->read(self::MB), ''); 184 | } 185 | 186 | public function testEmitsErrorWhenDecryptionFails() 187 | { 188 | // Capture the error in a custom handler to avoid PHPUnit's error trap 189 | set_error_handler(function ($_, $message) use (&$error) { 190 | $error = $message; 191 | }); 192 | 193 | // Trigger a decryption failure by attempting to decrypt gibberish 194 | // Not all cipher methods will balk (CTR, for example, will simply 195 | // decrypt gibberish into gibberish), so CBC is used. 196 | $_ = (string) new AesDecryptingStream(new RandomByteStream(self::MB), self::KEY, 197 | new Cbc(random_bytes(openssl_cipher_iv_length('aes-256-cbc')))); 198 | 199 | $this->assertRegExp("/DecryptionFailedException: Unable to decrypt/", $error); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/AesEncryptingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 29 | openssl_encrypt( 30 | $plainText, 31 | $iv->getOpenSslName(), 32 | self::KEY, 33 | OPENSSL_RAW_DATA, 34 | $iv->getCurrentIv() 35 | ), 36 | (string) new AesEncryptingStream( 37 | $plainTextStream, 38 | self::KEY, 39 | $iv 40 | ) 41 | ); 42 | } 43 | 44 | /** 45 | * @dataProvider cartesianJoinInputCipherMethodProvider 46 | * 47 | * @param StreamInterface $plainTextStream 48 | * @param string $plainText 49 | * @param CipherMethod $iv 50 | */ 51 | public function testSupportsReadingBeyondTheEndOfTheStream( 52 | StreamInterface $plainTextStream, 53 | string $plainText, 54 | CipherMethod $iv 55 | ) { 56 | $cipherText = openssl_encrypt( 57 | $plainText, 58 | $iv->getOpenSslName(), 59 | self::KEY, 60 | OPENSSL_RAW_DATA, 61 | $iv->getCurrentIv() 62 | ); 63 | $cipherStream = new AesEncryptingStream($plainTextStream, self::KEY, $iv); 64 | $this->assertSame($cipherText, $cipherStream->read(strlen($plainText) + self::MB)); 65 | $this->assertSame('', $cipherStream->read(self::MB)); 66 | } 67 | 68 | /** 69 | * @dataProvider cartesianJoinInputCipherMethodProvider 70 | * 71 | * @param StreamInterface $plainTextStream 72 | * @param string $plainText 73 | * @param CipherMethod $iv 74 | */ 75 | public function testSupportsRewinding( 76 | StreamInterface $plainTextStream, 77 | string $plainText, 78 | CipherMethod $iv 79 | ) { 80 | if (!$plainTextStream->isSeekable()) { 81 | $this->markTestSkipped('Cannot rewind encryption streams whose plaintext is not seekable'); 82 | } else { 83 | $cipherText = new AesEncryptingStream($plainTextStream, 'foo', $iv); 84 | $firstBytes = $cipherText->read(256 * 2 + 3); 85 | $cipherText->rewind(); 86 | $this->assertSame($firstBytes, $cipherText->read(256 * 2 + 3)); 87 | } 88 | } 89 | 90 | /** 91 | * @dataProvider cartesianJoinInputCipherMethodProvider 92 | * 93 | * @param StreamInterface $plainTextStream 94 | * @param string $plainText 95 | * @param CipherMethod $iv 96 | */ 97 | public function testAccuratelyReportsSizeOfCipherText( 98 | StreamInterface $plainTextStream, 99 | string $plainText, 100 | CipherMethod $iv 101 | ) { 102 | if ($plainTextStream->getSize() === null) { 103 | $this->markTestSkipped('Cannot read size of ciphertext stream when plaintext stream size is unknown'); 104 | } else { 105 | $cipherText = new AesEncryptingStream($plainTextStream, 'foo', $iv); 106 | $this->assertSame($cipherText->getSize(), strlen((string) $cipherText)); 107 | } 108 | } 109 | 110 | /** 111 | * @dataProvider cipherMethodProvider 112 | * 113 | * @param CipherMethod $cipherMethod 114 | */ 115 | public function testMemoryUsageRemainsConstant(CipherMethod $cipherMethod) 116 | { 117 | $memory = memory_get_usage(); 118 | 119 | $stream = new AesEncryptingStream( 120 | new RandomByteStream(124 * self::MB), 121 | 'foo', 122 | $cipherMethod 123 | ); 124 | 125 | while (!$stream->eof()) { 126 | $stream->read(self::MB); 127 | } 128 | 129 | // Reading 1MB chunks should take 2MB 130 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 131 | } 132 | 133 | public function testIsNotWritable() 134 | { 135 | $stream = new AesEncryptingStream( 136 | new RandomByteStream(124 * self::MB), 137 | 'foo', 138 | new Cbc(random_bytes(openssl_cipher_iv_length('aes-256-cbc'))) 139 | ); 140 | 141 | $this->assertFalse($stream->isWritable()); 142 | } 143 | 144 | /** 145 | * @dataProvider cipherMethodProvider 146 | * 147 | * @param CipherMethod $cipherMethod 148 | */ 149 | public function testReturnsPaddedOrEmptyStringWhenSourceStreamEmpty( 150 | CipherMethod $cipherMethod 151 | ){ 152 | $stream = new AesEncryptingStream( 153 | Psr7\stream_for(''), 154 | 'foo', 155 | $cipherMethod 156 | ); 157 | 158 | $paddingLength = $cipherMethod->requiresPadding() ? AesEncryptingStream::BLOCK_SIZE : 0; 159 | 160 | $this->assertSame($paddingLength, strlen($stream->read(self::MB))); 161 | $this->assertSame($stream->read(self::MB), ''); 162 | } 163 | 164 | /** 165 | * @dataProvider cipherMethodProvider 166 | * 167 | * @param CipherMethod $cipherMethod 168 | * 169 | * @expectedException \LogicException 170 | */ 171 | public function testDoesNotSupportSeekingFromEnd(CipherMethod $cipherMethod) 172 | { 173 | $stream = new AesEncryptingStream(Psr7\stream_for('foo'), 'foo', $cipherMethod); 174 | 175 | $stream->seek(1, SEEK_END); 176 | } 177 | 178 | /** 179 | * @dataProvider seekableCipherMethodProvider 180 | * 181 | * @param CipherMethod $cipherMethod 182 | */ 183 | public function testSupportsSeekingFromCurrentPosition( 184 | CipherMethod $cipherMethod 185 | ){ 186 | $stream = new AesEncryptingStream( 187 | Psr7\stream_for(random_bytes(2 * self::MB)), 188 | 'foo', 189 | $cipherMethod 190 | ); 191 | 192 | $lastFiveBytes = substr($stream->read(self::MB), self::MB - 5); 193 | $stream->seek(-5, SEEK_CUR); 194 | $this->assertSame($lastFiveBytes, $stream->read(5)); 195 | } 196 | public function testEmitsErrorWhenEncryptionFails() 197 | { 198 | // Capture the error in a custom handler to avoid PHPUnit's error trap 199 | set_error_handler(function ($_, $message) use (&$error) { 200 | $error = $message; 201 | }); 202 | 203 | // Trigger an openssl error by supplying an invalid key size 204 | $_ = (string) new AesEncryptingStream(new RandomByteStream(self::MB), self::KEY, 205 | new class implements CipherMethod { 206 | public function getCurrentIv(): string { return 'iv'; } 207 | 208 | public function getOpenSslName(): string { return 'aes-157-cbd'; } 209 | 210 | public function requiresPadding(): bool { return false; } 211 | 212 | public function update(string $cipherTextBlock): void {} 213 | 214 | public function seek(int $offset, int $whence = SEEK_SET): void {} 215 | }); 216 | 217 | $this->assertRegExp("/EncryptionFailedException: Unable to encrypt/", $error); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /tests/AesEncryptionStreamTestTrait.php: -------------------------------------------------------------------------------- 1 | unwrapProvider([$this, 'plainTextProvider']); 12 | 13 | for ($i = 0; $i < count($plainTexts); $i++) { 14 | for ($j = 0; $j < count($this->cipherMethodProvider()); $j++) { 15 | $toReturn []= [ 16 | // Test each string with standard temp streams 17 | Psr7\stream_for($plainTexts[$i]), 18 | $plainTexts[$i], 19 | $this->cipherMethodProvider()[$j][0] 20 | ]; 21 | 22 | $toReturn []= [ 23 | // Test each string with a stream that does not know its own size 24 | Psr7\stream_for((function ($pt) { yield $pt; })($plainTexts[$i])), 25 | $plainTexts[$i], 26 | $this->cipherMethodProvider()[$j][0] 27 | ]; 28 | } 29 | } 30 | 31 | return $toReturn; 32 | } 33 | 34 | public function cartesianJoinInputKeySizeProvider() 35 | { 36 | $toReturn = []; 37 | $plainTexts = $this->unwrapProvider([$this, 'plainTextProvider']); 38 | $keySizes = $this->unwrapProvider([$this, 'keySizeProvider']); 39 | 40 | for ($i = 0; $i < count($plainTexts); $i++) { 41 | for ($j = 0; $j < count($keySizes); $j++) { 42 | $toReturn []= [ 43 | // Test each string with standard temp streams 44 | Psr7\stream_for($plainTexts[$i]), 45 | $plainTexts[$i], 46 | $keySizes[$j], 47 | ]; 48 | 49 | $toReturn []= [ 50 | // Test each string with a stream that does not know its own size 51 | Psr7\stream_for((function ($pt) { yield $pt; })($plainTexts[$i])), 52 | $plainTexts[$i], 53 | $keySizes[$j], 54 | ]; 55 | } 56 | } 57 | 58 | return $toReturn; 59 | } 60 | 61 | public function cipherMethodProvider() 62 | { 63 | $toReturn = []; 64 | foreach ($this->unwrapProvider([$this, 'keySizeProvider']) as $keySize) { 65 | $toReturn []= [new Cbc( 66 | random_bytes(openssl_cipher_iv_length('aes-256-cbc')), 67 | $keySize 68 | )]; 69 | $toReturn []= [new Ctr( 70 | random_bytes(openssl_cipher_iv_length('aes-256-ctr')), 71 | $keySize 72 | )]; 73 | $toReturn []= [new Ecb($keySize)]; 74 | } 75 | 76 | return $toReturn; 77 | } 78 | 79 | public function seekableCipherMethodProvider() 80 | { 81 | return array_filter($this->cipherMethodProvider(), function (array $args) { 82 | return !($args[0] instanceof Cbc); 83 | }); 84 | } 85 | 86 | public function keySizeProvider() 87 | { 88 | return [ 89 | [128], 90 | [192], 91 | [256], 92 | ]; 93 | } 94 | 95 | public function plainTextProvider() { 96 | return [ 97 | ['The rain in Spain falls mainly on the plain.'], 98 | ['دست‌نوشته‌ها نمی‌سوزند'], 99 | ['Рукописи не горят'], 100 | ['test'], 101 | [random_bytes(AesEncryptingStream::BLOCK_SIZE)], 102 | [random_bytes(2 * 1024 * 1024)], 103 | [random_bytes(2 * 1024 * 1024 + 11)], 104 | ]; 105 | } 106 | 107 | private function unwrapProvider(callable $provider) 108 | { 109 | return array_map(function (array $wrapped) { 110 | return $wrapped[0]; 111 | }, call_user_func($provider)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/AesGcmDecryptingStreamTest.php: -------------------------------------------------------------------------------- 1 | 'bar']); 24 | $tag = null; 25 | $cipherText = openssl_encrypt( 26 | $plainText, 27 | "aes-{$keySize}-gcm", 28 | self::KEY, 29 | OPENSSL_RAW_DATA, 30 | $iv, 31 | $tag, 32 | $additionalData, 33 | 16 34 | ); 35 | 36 | $decryptingStream = new AesGcmDecryptingStream( 37 | Psr7\stream_for($cipherText), 38 | self::KEY, 39 | $iv, 40 | $tag, 41 | $additionalData, 42 | 16, 43 | $keySize 44 | ); 45 | 46 | $this->assertSame((string) $decryptingStream, $plainText); 47 | } 48 | 49 | public function testIsNotWritable() 50 | { 51 | $decryptingStream = new AesGcmDecryptingStream( 52 | Psr7\stream_for(''), 53 | self::KEY, 54 | random_bytes(openssl_cipher_iv_length('aes-256-gcm')), 55 | 'tag' 56 | ); 57 | 58 | $this->assertFalse($decryptingStream->isWritable()); 59 | } 60 | 61 | public function testEmitsErrorWhenDecryptionFails() 62 | { 63 | // Capture the error in a custom handler to avoid PHPUnit's error trap 64 | set_error_handler(function ($_, $message) use (&$error) { 65 | $error = $message; 66 | }); 67 | 68 | // Trigger a decryption failure by attempting to decrypt gibberish 69 | $_ = (string) new AesGcmDecryptingStream( 70 | new RandomByteStream(1024 * 1024), 71 | self::KEY, 72 | random_bytes(openssl_cipher_iv_length('aes-256-gcm')), 73 | 'tag' 74 | ); 75 | 76 | $this->assertRegExp("/DecryptionFailedException: Unable to decrypt/", $error); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/AesGcmEncryptingStreamTest.php: -------------------------------------------------------------------------------- 1 | 'bar']); 24 | $tag = null; 25 | $encryptingStream = new AesGcmEncryptingStream( 26 | $plainTextStream, 27 | self::KEY, 28 | $iv, 29 | $additionalData, 30 | 16, 31 | $keySize 32 | ); 33 | 34 | $this->assertSame( 35 | (string) $encryptingStream, 36 | openssl_encrypt( 37 | $plainText, 38 | "aes-{$keySize}-gcm", 39 | self::KEY, 40 | OPENSSL_RAW_DATA, 41 | $iv, 42 | $tag, 43 | $additionalData, 44 | 16 45 | ) 46 | ); 47 | 48 | $this->assertSame($tag, $encryptingStream->getTag()); 49 | } 50 | 51 | public function testIsNotWritable() 52 | { 53 | $decryptingStream = new AesGcmEncryptingStream( 54 | Psr7\stream_for(''), 55 | self::KEY, 56 | random_bytes(openssl_cipher_iv_length('aes-256-gcm')) 57 | ); 58 | 59 | $this->assertFalse($decryptingStream->isWritable()); 60 | } 61 | 62 | public function testEmitsErrorWhenEncryptionFails() 63 | { 64 | // Capture the error in a custom handler to avoid PHPUnit's error trap 65 | set_error_handler(function ($_, $message) use (&$error) { 66 | $error = $message; 67 | }); 68 | 69 | // Trigger a decryption failure by attempting to decrypt gibberish 70 | $_ = (string) new AesGcmEncryptingStream( 71 | new RandomByteStream(1024 * 1024), 72 | self::KEY, 73 | random_bytes(openssl_cipher_iv_length('aes-256-gcm')), 74 | 'tag', 75 | 16, 76 | 157 77 | ); 78 | 79 | $this->assertRegExp("/EncryptionFailedException: Unable to encrypt/", $error); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Base64DecodingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame(base64_decode($stream), (string) $encodingStream); 17 | } 18 | 19 | public function testShouldReportNullAsSize() 20 | { 21 | $encodingStream = new Base64DecodingStream( 22 | Psr7\stream_for(base64_encode(random_bytes(1027))) 23 | ); 24 | 25 | $this->assertNull($encodingStream->getSize()); 26 | } 27 | 28 | public function testMemoryUsageRemainsConstant() 29 | { 30 | $memory = memory_get_usage(); 31 | 32 | $stream = new Base64DecodingStream( 33 | new Base64EncodingStream(new RandomByteStream(124 * self::MB)) 34 | ); 35 | 36 | while (!$stream->eof()) { 37 | $stream->read(self::MB); 38 | } 39 | 40 | // Reading 1MB chunks should take 2MB 41 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Base64EncodingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame(base64_encode($bytes), (string) $encodingStream); 18 | } 19 | 20 | public function testShouldReportSizeOfEncodedStream() 21 | { 22 | $bytes = random_bytes(self::MB + 3); 23 | $encodingStream = new Base64EncodingStream(Psr7\stream_for($bytes)); 24 | 25 | $this->assertSame(strlen(base64_encode($bytes)), $encodingStream->getSize()); 26 | } 27 | 28 | public function testShouldReportNullIfSizeOfSourceStreamUnknown() 29 | { 30 | $stream = new PumpStream(function ($length) { 31 | return random_bytes($length); 32 | }); 33 | $encodingStream = new Base64EncodingStream($stream); 34 | 35 | $this->assertNull($encodingStream->getSize()); 36 | } 37 | 38 | public function testMemoryUsageRemainsConstant() 39 | { 40 | $memory = memory_get_usage(); 41 | 42 | $stream = new Base64EncodingStream(new RandomByteStream(124 * self::MB)); 43 | 44 | while (!$stream->eof()) { 45 | $stream->read(self::MB); 46 | } 47 | 48 | // Reading 1MB chunks should take 2MB 49 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/CbcTest.php: -------------------------------------------------------------------------------- 1 | assertSame('aes-256-cbc', (new Cbc($ivString))->getOpenSslName()); 12 | } 13 | 14 | public function testShouldReturnInitialIvStringForCurrentIvBeforeUpdate() 15 | { 16 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 17 | $iv = new Cbc($ivString); 18 | 19 | $this->assertSame($ivString, $iv->getCurrentIv()); 20 | } 21 | 22 | public function testUpdateShouldSetCurrentIvToEndOfCipherBlock() 23 | { 24 | $ivLength = openssl_cipher_iv_length('aes-256-cbc'); 25 | $ivString = random_bytes($ivLength); 26 | $iv = new Cbc($ivString); 27 | $cipherTextBlock = random_bytes(1024); 28 | 29 | $iv->update($cipherTextBlock); 30 | $this->assertNotSame($ivString, $iv->getCurrentIv()); 31 | $this->assertSame( 32 | substr($cipherTextBlock, $ivLength * -1), 33 | $iv->getCurrentIv() 34 | ); 35 | } 36 | 37 | /** 38 | * @expectedException \InvalidArgumentException 39 | */ 40 | public function testShouldThrowWhenIvOfInvalidLengthProvided() 41 | { 42 | new Cbc(random_bytes(openssl_cipher_iv_length('aes-256-cbc') + 1)); 43 | } 44 | 45 | public function testShouldSupportSeekingToBeginning() 46 | { 47 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 48 | $iv = new Cbc($ivString); 49 | $cipherTextBlock = random_bytes(1024); 50 | 51 | $iv->update($cipherTextBlock); 52 | $iv->seek(0); 53 | $this->assertSame($ivString, $iv->getCurrentIv()); 54 | } 55 | 56 | /** 57 | * @expectedException \LogicException 58 | */ 59 | public function testShouldThrowWhenNonZeroOffsetProvidedToSeek() 60 | { 61 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 62 | $iv = new Cbc($ivString); 63 | $cipherTextBlock = random_bytes(1024); 64 | 65 | $iv->update($cipherTextBlock); 66 | $iv->seek(1); 67 | } 68 | 69 | /** 70 | * @expectedException \LogicException 71 | */ 72 | public function testShouldThrowWhenSeekCurProvidedToSeek() 73 | { 74 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 75 | $iv = new Cbc($ivString); 76 | $cipherTextBlock = random_bytes(1024); 77 | 78 | $iv->update($cipherTextBlock); 79 | $iv->seek(0, SEEK_CUR); 80 | } 81 | 82 | /** 83 | * @expectedException \LogicException 84 | */ 85 | public function testShouldThrowWhenSeekEndProvidedToSeek() 86 | { 87 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); 88 | $iv = new Cbc($ivString); 89 | $cipherTextBlock = random_bytes(1024); 90 | 91 | $iv->update($cipherTextBlock); 92 | $iv->seek(0, SEEK_END); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/CtrTest.php: -------------------------------------------------------------------------------- 1 | assertSame('aes-256-ctr', (new Ctr($ivString))->getOpenSslName()); 12 | } 13 | 14 | public function testShouldReturnInitialIvStringForCurrentIvBeforeUpdate() 15 | { 16 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 17 | $iv = new Ctr($ivString); 18 | 19 | $this->assertSame($ivString, $iv->getCurrentIv()); 20 | } 21 | 22 | public function testUpdateShouldSetIncrementIvByNumberOfBlocksProcessed() 23 | { 24 | $ivString = $iv = hex2bin('deadbeefdeadbeefdeadbeefdeadbeee'); 25 | $iv = new Ctr($ivString); 26 | $cipherTextBlock = random_bytes(Ctr::BLOCK_SIZE); 27 | 28 | $iv->update($cipherTextBlock); 29 | $this->assertNotSame($ivString, $iv->getCurrentIv()); 30 | $this->assertSame( 31 | hex2bin('deadbeefdeadbeefdeadbeefdeadbeef'), 32 | $iv->getCurrentIv() 33 | ); 34 | } 35 | 36 | /** 37 | * @expectedException \InvalidArgumentException 38 | */ 39 | public function testShouldThrowWhenIvOfInvalidLengthProvided() 40 | { 41 | new Ctr(random_bytes(openssl_cipher_iv_length('aes-256-ctr') + 1)); 42 | } 43 | 44 | public function testShouldSupportSeekingToBeginning() 45 | { 46 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 47 | $iv = new Ctr($ivString); 48 | $cipherTextBlock = random_bytes(1024); 49 | 50 | $iv->update($cipherTextBlock); 51 | $iv->seek(0); 52 | $this->assertSame($ivString, $iv->getCurrentIv()); 53 | } 54 | 55 | public function testShouldSupportSeekingFromCurrentPosition() 56 | { 57 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 58 | $iv = new Ctr($ivString); 59 | $cipherTextBlock = random_bytes(1024); 60 | 61 | $iv->update($cipherTextBlock); 62 | $updatedIv = $iv->getCurrentIv(); 63 | $iv->seek(Ctr::BLOCK_SIZE, SEEK_CUR); 64 | $this->assertNotSame($updatedIv, $iv->getCurrentIv()); 65 | } 66 | 67 | /** 68 | * @expectedException \LogicException 69 | */ 70 | public function testShouldThrowWhenSeekOffsetNotDivisibleByBlockSize() 71 | { 72 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 73 | $iv = new Ctr($ivString); 74 | $cipherTextBlock = random_bytes(1024); 75 | 76 | $iv->update($cipherTextBlock); 77 | $iv->seek(1); 78 | } 79 | 80 | /** 81 | * @expectedException \LogicException 82 | */ 83 | public function testShouldThrowWhenNegativeSeekCurProvidedToSeek() 84 | { 85 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 86 | $iv = new Ctr($ivString); 87 | $cipherTextBlock = random_bytes(1024); 88 | 89 | $iv->update($cipherTextBlock); 90 | $iv->seek(Ctr::BLOCK_SIZE * -1, SEEK_CUR); 91 | } 92 | 93 | /** 94 | * @expectedException \LogicException 95 | */ 96 | public function testShouldThrowWhenSeekEndProvidedToSeek() 97 | { 98 | $ivString = random_bytes(openssl_cipher_iv_length('aes-256-ctr')); 99 | $iv = new Ctr($ivString); 100 | $cipherTextBlock = random_bytes(1024); 101 | 102 | $iv->update($cipherTextBlock); 103 | $iv->seek(0, SEEK_END); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/EcbTest.php: -------------------------------------------------------------------------------- 1 | assertSame('aes-256-ecb', (new Ecb)->getOpenSslName()); 11 | } 12 | 13 | public function testShouldReturnEmptyStringForCurrentIv() 14 | { 15 | $iv = new Ecb(); 16 | $this->assertEmpty($iv->getCurrentIv()); 17 | $iv->update(random_bytes(128)); 18 | $this->assertEmpty($iv->getCurrentIv()); 19 | } 20 | 21 | public function testSeekShouldBeNoOp() 22 | { 23 | $iv = new Ecb(); 24 | $baseIv = $iv->getCurrentIv(); 25 | $iv->update(random_bytes(128)); 26 | $this->assertSame($baseIv, $iv->getCurrentIv()); 27 | } 28 | 29 | public function testShouldReportThatPaddingIsRequired() 30 | { 31 | $this->assertTrue((new Ecb)->requiresPadding()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/HashingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame(hash($algorithm, $toHash, true), $hash); 23 | }, 24 | $algorithm 25 | ); 26 | 27 | $instance->getContents(); 28 | 29 | $this->assertSame( 30 | hash($algorithm, $toHash, true), 31 | $instance->getHash() 32 | ); 33 | } 34 | 35 | /** 36 | * @dataProvider hmacAlgorithmProvider 37 | * 38 | * @param string $algorithm 39 | */ 40 | public function testAuthenticatedHashShouldMatchThatReturnedByHashMethod( 41 | $algorithm 42 | ) { 43 | $key = 'secret key'; 44 | $toHash = random_bytes(1025); 45 | $instance = new HashingStream( 46 | Psr7\stream_for($toHash), 47 | $key, 48 | function ($hash) use ($toHash, $key, $algorithm) { 49 | $this->assertSame( 50 | hash_hmac($algorithm, $toHash, $key, true), 51 | $hash 52 | ); 53 | }, 54 | $algorithm 55 | ); 56 | 57 | $instance->getContents(); 58 | 59 | $this->assertSame( 60 | hash_hmac($algorithm, $toHash, $key, true), 61 | $instance->getHash() 62 | ); 63 | } 64 | 65 | /** 66 | * @dataProvider hmacAlgorithmProvider 67 | * 68 | * @param string $algorithm 69 | */ 70 | public function testHashingStreamsCanBeRewound($algorithm) 71 | { 72 | $key = 'secret key'; 73 | $toHash = random_bytes(1025); 74 | $callCount = 0; 75 | $instance = new HashingStream( 76 | Psr7\stream_for($toHash), 77 | $key, 78 | function ($hash) use ($toHash, $key, $algorithm, &$callCount) { 79 | ++$callCount; 80 | $this->assertSame( 81 | hash_hmac($algorithm, $toHash, $key, true), 82 | $hash 83 | ); 84 | }, 85 | $algorithm 86 | ); 87 | 88 | $instance->getContents(); 89 | $instance->rewind(); 90 | $instance->getContents(); 91 | 92 | $this->assertSame(2, $callCount); 93 | } 94 | 95 | public function hmacAlgorithmProvider() 96 | { 97 | $cryptoHashes = []; 98 | foreach (hash_algos() as $algo) { 99 | // As of PHP 7.2, feeding a non-cryptographic hashing 100 | // algorithm to `hash_init` will trigger an error, and 101 | // feeding one to `hash_hmac` will cause the function to 102 | // return `false`. 103 | // cf https://www.php.net/manual/en/migration72.incompatible.php#migration72.incompatible.hash-functions 104 | if (@hash_hmac($algo, 'data', 'secret key')) { 105 | $cryptoHashes []= [$algo]; 106 | } 107 | } 108 | 109 | return $cryptoHashes; 110 | } 111 | 112 | public function hashAlgorithmProvider() 113 | { 114 | return array_map(function ($algo) { return [$algo]; }, hash_algos()); 115 | } 116 | 117 | /** 118 | * @expectedException \LogicException 119 | */ 120 | public function testDoesNotSupportArbitrarySeeking() 121 | { 122 | $instance = new HashingStream(Psr7\stream_for(random_bytes(1025))); 123 | $instance->seek(1); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/HexDecodingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame(hex2bin($stream), (string) $encodingStream); 18 | } 19 | 20 | public function testShouldReportSizeOfDecodedStream() 21 | { 22 | $stream = Psr7\stream_for(bin2hex(random_bytes(1027))); 23 | $encodingStream = new HexDecodingStream($stream); 24 | 25 | $this->assertSame(strlen(hex2bin($stream)), $encodingStream->getSize()); 26 | } 27 | 28 | public function testShouldReportNullIfSizeOfSourceStreamUnknown() 29 | { 30 | $stream = new PumpStream(function () { 31 | return bin2hex(random_bytes(self::MB)); 32 | }); 33 | $encodingStream = new HexDecodingStream($stream); 34 | 35 | $this->assertNull($encodingStream->getSize()); 36 | } 37 | 38 | public function testMemoryUsageRemainsConstant() 39 | { 40 | $memory = memory_get_usage(); 41 | 42 | $stream = new HexDecodingStream( 43 | new HexEncodingStream(new RandomByteStream(124 * self::MB)) 44 | ); 45 | 46 | while (!$stream->eof()) { 47 | $stream->read(self::MB); 48 | } 49 | 50 | // Reading 1MB chunks should take 2MB 51 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/HexEncodingStreamTest.php: -------------------------------------------------------------------------------- 1 | assertSame(bin2hex($bytes), (string) $encodingStream); 18 | } 19 | 20 | public function testShouldReportSizeOfEncodedStream() 21 | { 22 | $bytes = random_bytes(self::MB + 3); 23 | $encodingStream = new HexEncodingStream(Psr7\stream_for($bytes)); 24 | 25 | $this->assertSame(strlen(bin2hex($bytes)), $encodingStream->getSize()); 26 | } 27 | 28 | public function testShouldReportNullIfSizeOfSourceStreamUnknown() 29 | { 30 | $stream = new PumpStream(function ($length) { 31 | return random_bytes($length); 32 | }); 33 | $encodingStream = new HexEncodingStream($stream); 34 | 35 | $this->assertNull($encodingStream->getSize()); 36 | } 37 | 38 | public function testMemoryUsageRemainsConstant() 39 | { 40 | $memory = memory_get_usage(); 41 | 42 | $stream = new HexEncodingStream(new RandomByteStream(124 * self::MB)); 43 | 44 | while (!$stream->eof()) { 45 | $stream->read(self::MB); 46 | } 47 | 48 | // Reading 1MB chunks should take 2MB 49 | $this->assertLessThanOrEqual($memory + 2 * self::MB, memory_get_usage()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/RandomByteStream.php: -------------------------------------------------------------------------------- 1 | maxLength = $maxLength; 28 | $this->stream = new PumpStream(function ($length) use (&$maxLength) { 29 | $length = min($length, $maxLength); 30 | $maxLength -= $length; 31 | return $length > 0 ? random_bytes($length) : false; 32 | }); 33 | } 34 | 35 | public function getSize() 36 | { 37 | return $this->maxLength; 38 | } 39 | } 40 | --------------------------------------------------------------------------------