├── .coveralls.yml ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Cryptor.php ├── CryptorInterface.php ├── Exceptions │ ├── Concerns │ │ └── HasOriginalMessage.php │ ├── DecryptionFailedException.php │ ├── EasyCryptException.php │ ├── EncryptionFailedException.php │ └── UnsupportedCipherException.php ├── FixedPasswordCryptor.php ├── FixedPasswordCryptorInterface.php ├── IvGenerator │ ├── IvGeneratorInterface.php │ └── RandomIvGenerator.php └── OpenSSL │ ├── EncryptionResult.php │ └── OpenSSL.php └── tests ├── CryptorTest.php └── FixedPasswordCryptorTest.php /.coveralls.yml: -------------------------------------------------------------------------------- 1 | coverage_clover: build/logs/clover.xml 2 | json_path: build/logs/coveralls-upload.json 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | php: [8.2, 8.3, 8.4] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php }} 20 | coverage: xdebug 21 | 22 | - run: composer update 23 | - run: mkdir -p build/logs 24 | - run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml 25 | 26 | - name: Upload Coverage 27 | uses: nick-invision/retry@v2 28 | env: 29 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | COVERALLS_PARALLEL: 'true' 31 | COVERALLS_FLAG_NAME: 'php:${{ matrix.php }}' 32 | with: 33 | timeout_minutes: 1 34 | max_attempts: 3 35 | command: | 36 | composer global require php-coveralls/php-coveralls 37 | php-coveralls --coverage_clover=build/logs/clover.xml -v 38 | 39 | coverage-aggregation: 40 | needs: build 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Aggregate Coverage 44 | uses: coverallsapp/github-action@master 45 | with: 46 | github-token: ${{ secrets.GITHUB_TOKEN }} 47 | parallel-finished: true 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .php_cs.cache 3 | .phpunit.result.cache 4 | composer.lock 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 mpyw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EasyCrypt [![Build Status](https://github.com/mpyw/EasyCrypt/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/mpyw/EasyCrypt/actions) [![Coverage Status](https://coveralls.io/repos/github/mpyw/EasyCrypt/badge.svg?branch=master)](https://coveralls.io/github/mpyw/EasyCrypt?branch=master) 2 | 3 | A class that provides simple interface for **decryptable** encryption. 4 | 5 | ## Requirements 6 | 7 | - PHP: `^8.2` 8 | 9 | > [!NOTE] 10 | > Older versions have outdated dependency requirements. If you cannot prepare the latest environment, please refer to past releases. 11 | 12 | ## Installing 13 | 14 | ``` 15 | composer require mpyw/easycrypt 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### Basic 21 | 22 | The default cipher method is `aes256` (`aes-256-cbc`). 23 | 24 | ```php 25 | encrypt($secretData, $password); 35 | $decrypted = $cryptor->decrypt($encrypted, $password); // String on success, false on failure. 36 | 37 | var_dump($secretData === $decrypted); // bool(true) 38 | ``` 39 | 40 | ### Throw `DecryptionFailedException` when decryption failed 41 | 42 | It throws `DecryptionFailedException` instead of returning false. 43 | 44 | ```php 45 | $decrypted = $cryptor->mustDecrypt($encrypted, $password); 46 | ``` 47 | 48 | ### Use fixed password 49 | 50 | You can use `FixedPasswordCryptor` instead of raw `Cryptor`. 51 | This is useful when we use a fixed password from an application config. 52 | 53 | ```php 54 | encrypt($secretData); 63 | $decrypted = $cryptor->decrypt($encrypted); // String on success, false on failure. 64 | 65 | var_dump($secretData === $decrypted); // bool(true) 66 | ``` 67 | 68 | ### Use AEAD (Authenticated Encryption with Associated Data) suites 69 | 70 | If you need to use AEAD suites that adopt CTR mode, it is recommended to provide truly unique counter value. 71 | 72 | ```php 73 | use Mpyw\EasyCrypt\IvGenerator\IvGeneratorInterface; 74 | 75 | class Counter implements IvGeneratorInterface 76 | { 77 | protected \PDO $pdo; 78 | 79 | public function __construct(\PDO $pdo) 80 | { 81 | $this->pdo = $pdo; 82 | } 83 | 84 | public function generate(int $length): string 85 | { 86 | $this->pdo->exec('INSERT INTO counters()'); 87 | return $this->pdo->lastInsertId(); 88 | } 89 | } 90 | ``` 91 | 92 | ```php 93 | =11.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | ./src 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Cryptor.php: -------------------------------------------------------------------------------- 1 | openssl = new OpenSSL($method); 33 | $this->ivGenerator = $ivGenerator ?? new RandomIvGenerator(); 34 | } 35 | 36 | /** 37 | * Encrypt with shared password. 38 | * 39 | * @param string $data 40 | * @param string $password 41 | * @return string Binary string. 42 | */ 43 | public function encrypt(string $data, string $password): string 44 | { 45 | $iv = $this->ivGenerator->generate($this->openssl->ivLength()); 46 | $encrypted = $this->openssl->encrypt($data, $password, $iv); 47 | 48 | return "$iv{$encrypted->data}{$encrypted->tag}"; 49 | } 50 | 51 | /** 52 | * Decrypt with shared password. 53 | * 54 | * @param string $data Binary string. 55 | * @param string $password 56 | * @return bool|string String on success, false on failure. 57 | */ 58 | public function decrypt(string $data, string $password) 59 | { 60 | try { 61 | return $this->mustDecrypt($data, $password); 62 | } catch (DecryptionFailedException $e) { 63 | return false; 64 | } 65 | } 66 | 67 | /** 68 | * Decrypt with shared password. 69 | * 70 | * @param string $data Binary string. 71 | * @param string $password 72 | * @throws \Mpyw\EasyCrypt\Exceptions\DecryptionFailedException 73 | * @return string Return string on success. 74 | */ 75 | public function mustDecrypt(string $data, string $password): string 76 | { 77 | $originalData = $data; 78 | 79 | $iv = static::cutFragmentFrom($data, $this->openssl->ivLength()); 80 | if (strlen($iv) !== $this->openssl->ivLength()) { 81 | throw new DecryptionFailedException('invalid iv length.', $originalData); 82 | } 83 | 84 | if ($this->openssl->useTag()) { 85 | $tag = static::cutFragmentFrom($data, -$this->openssl->tagLength()); 86 | if (strlen($tag) !== $this->openssl->tagLength()) { 87 | throw new DecryptionFailedException('invalid tag length.', $originalData); 88 | } 89 | } 90 | 91 | return $this->openssl->decrypt($data, $originalData, $password, $iv, $tag ?? null); 92 | } 93 | 94 | protected static function cutFragmentFrom(string &$data, int $length): string 95 | { 96 | [$fragment, $data] = [substr($data, 0, $length), substr($data, $length)]; 97 | 98 | if ($length < 0) { 99 | [$fragment, $data] = [$data, $fragment]; 100 | } 101 | 102 | return $fragment; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/CryptorInterface.php: -------------------------------------------------------------------------------- 1 | originalMessage; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/DecryptionFailedException.php: -------------------------------------------------------------------------------- 1 | data = $data; 27 | $this->originalMessage = $originalMessage; 28 | 29 | parent::__construct('Failed to decrypt.', 0, $previous); 30 | } 31 | 32 | /** 33 | * @return string 34 | */ 35 | public function getData(): string 36 | { 37 | return $this->data; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exceptions/EasyCryptException.php: -------------------------------------------------------------------------------- 1 | originalMessage = $message; 24 | 25 | parent::__construct('Failed to encrypt.', 0, $previous); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/UnsupportedCipherException.php: -------------------------------------------------------------------------------- 1 | password = $password; 28 | $this->cryptor = $cryptor ?? new Cryptor(); 29 | } 30 | 31 | /** 32 | * Encrypt with shared password. 33 | * 34 | * @param string $data 35 | * @return string Binary string. 36 | */ 37 | public function encrypt(string $data): string 38 | { 39 | return $this->cryptor->encrypt($data, $this->password); 40 | } 41 | 42 | /** 43 | * Decrypt with shared password. 44 | * 45 | * @param string $data Binary string. 46 | * @return bool|string String on success, false on failure. 47 | */ 48 | public function decrypt(string $data) 49 | { 50 | return $this->cryptor->decrypt($data, $this->password); 51 | } 52 | 53 | /** 54 | * Decrypt with shared password. 55 | * 56 | * @param string $data Binary string. 57 | * @throws DecryptionFailedException 58 | * @return string Return string on success. 59 | */ 60 | public function mustDecrypt(string $data): string 61 | { 62 | return $this->cryptor->mustDecrypt($data, $this->password); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/FixedPasswordCryptorInterface.php: -------------------------------------------------------------------------------- 1 | data = $data; 26 | $this->tag = $tag; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/OpenSSL/OpenSSL.php: -------------------------------------------------------------------------------- 1 | method = $method; 41 | } 42 | 43 | /** 44 | * @param string $data 45 | * @param string $password 46 | * @param string $iv 47 | * @return EncryptionResult 48 | */ 49 | public function encrypt(string $data, string $password, string $iv = ''): EncryptionResult 50 | { 51 | $encrypted = $this->useTag() 52 | ? openssl_encrypt($data, $this->method, $password, OPENSSL_RAW_DATA, $iv, $tag) 53 | : openssl_encrypt($data, $this->method, $password, OPENSSL_RAW_DATA, $iv); 54 | 55 | if ($encrypted === false) { 56 | // @codeCoverageIgnoreStart 57 | throw new EncryptionFailedException(openssl_error_string()); 58 | // @codeCoverageIgnoreEnd 59 | } 60 | 61 | return new EncryptionResult($encrypted, $tag ?? null); 62 | } 63 | 64 | /** 65 | * @param string $data 66 | * @param string $originalData 67 | * @param string $password 68 | * @param string $iv 69 | * @param null|string $tag 70 | * @throws DecryptionFailedException 71 | * @return string 72 | */ 73 | public function decrypt(string $data, string $originalData, string $password, string $iv = '', ?string $tag = null): string 74 | { 75 | $decrypted = $this->useTag() 76 | ? openssl_decrypt($data, $this->method, $password, OPENSSL_RAW_DATA, $iv, $tag ?? '') 77 | : openssl_decrypt($data, $this->method, $password, OPENSSL_RAW_DATA, $iv); 78 | 79 | if ($decrypted === false) { 80 | $error = openssl_error_string() ?: ($this->useTag() ? 'invalid tag content.' : 'unknown error.'); 81 | throw new DecryptionFailedException($error, $originalData); 82 | } 83 | 84 | return $decrypted; 85 | } 86 | 87 | /** 88 | * @return int 89 | */ 90 | public function ivLength(): int 91 | { 92 | return $this->ivLength ?? ($this->ivLength = openssl_cipher_iv_length($this->method)); 93 | } 94 | 95 | /** 96 | * @return null|int 97 | */ 98 | public function tagLength(): ?int 99 | { 100 | if (!is_bool($this->tagLength)) { 101 | return $this->tagLength; 102 | } 103 | 104 | set_error_handler(static function () {}); 105 | openssl_encrypt('', $this->method, '', OPENSSL_RAW_DATA, (new RandomIvGenerator())->generate($this->ivLength()), $tag); 106 | restore_error_handler(); 107 | 108 | return $this->tagLength = $tag === null ? null : strlen($tag); 109 | } 110 | 111 | /** 112 | * @return bool 113 | */ 114 | public function useTag(): bool 115 | { 116 | return $this->tagLength() !== null; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/CryptorTest.php: -------------------------------------------------------------------------------- 1 | expectException(UnsupportedCipherException::class); 18 | new Cryptor('invalid'); 19 | } 20 | 21 | public function testAes256(): void 22 | { 23 | $cryptor = new Cryptor(); 24 | 25 | $encryptedA = $cryptor->encrypt('data', 'password'); 26 | $encryptedB = $cryptor->encrypt('data', 'password'); 27 | 28 | $this->assertSame('data', $cryptor->decrypt($encryptedA, 'password')); 29 | $this->assertSame('data', $cryptor->decrypt($encryptedB, 'password')); 30 | $this->assertNotSame($encryptedA, $encryptedB); 31 | 32 | $this->assertFalse($cryptor->decrypt($encryptedA, 'passward')); 33 | } 34 | 35 | public function testAes256Gcm(): void 36 | { 37 | $cryptor = new Cryptor('aes-256-gcm'); 38 | 39 | $encryptedA = $cryptor->encrypt('data', 'password'); 40 | $encryptedB = $cryptor->encrypt('data', 'password'); 41 | 42 | $this->assertSame('data', $cryptor->decrypt($encryptedA, 'password')); 43 | $this->assertSame('data', $cryptor->decrypt($encryptedB, 'password')); 44 | $this->assertNotSame($encryptedA, $encryptedB); 45 | 46 | $this->assertFalse($cryptor->decrypt($encryptedA, 'passward')); 47 | } 48 | 49 | public function testInvalidIvLength(): void 50 | { 51 | $cryptor = new Cryptor(); 52 | $this->assertFalse($cryptor->decrypt('', 'password')); 53 | 54 | try { 55 | $cryptor->mustDecrypt('', 'password'); 56 | $this->assertTrue(false); 57 | } catch (DecryptionFailedException $e) { 58 | $this->assertSame('', $e->getData()); 59 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 60 | $this->assertSame('invalid iv length.', $e->getOriginalMessage()); 61 | } 62 | } 63 | 64 | public function testInvalidTagLength(): void 65 | { 66 | $cryptor = new Cryptor('aes-256-gcm'); 67 | $this->assertFalse($cryptor->decrypt(str_repeat('x', 16), 'password')); 68 | 69 | try { 70 | $cryptor->mustDecrypt(str_repeat('x', 16), 'password'); 71 | $this->assertTrue(false); 72 | } catch (DecryptionFailedException $e) { 73 | $this->assertSame(str_repeat('x', 16), $e->getData()); 74 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 75 | $this->assertSame('invalid tag length.', $e->getOriginalMessage()); 76 | } 77 | } 78 | 79 | public function testInvalidTagContent(): void 80 | { 81 | $cryptor = new Cryptor('aes-256-gcm'); 82 | 83 | $corrupted = substr_replace( 84 | $cryptor->encrypt('', 'password'), 85 | str_repeat('x', 16), 86 | -16 87 | ); 88 | 89 | $this->assertFalse($cryptor->decrypt($corrupted, 'password')); 90 | 91 | try { 92 | $cryptor->mustDecrypt($corrupted, 'password'); 93 | $this->assertTrue(false); 94 | } catch (DecryptionFailedException $e) { 95 | $this->assertSame($corrupted, $e->getData()); 96 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 97 | $this->assertSame('invalid tag content.', $e->getOriginalMessage()); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/FixedPasswordCryptorTest.php: -------------------------------------------------------------------------------- 1 | encrypt('data'); 21 | $encryptedB = $cryptor->encrypt('data'); 22 | 23 | $this->assertSame('data', $cryptor->decrypt($encryptedA)); 24 | $this->assertSame('data', $cryptor->decrypt($encryptedB)); 25 | $this->assertNotSame($encryptedA, $encryptedB); 26 | 27 | $this->assertFalse($anotherCryptor->decrypt($encryptedA)); 28 | } 29 | 30 | public function testInvalidIvLength(): void 31 | { 32 | $cryptor = new FixedPasswordCryptor('password'); 33 | $this->assertFalse($cryptor->decrypt('')); 34 | 35 | try { 36 | $cryptor->mustDecrypt(''); 37 | $this->assertTrue(false); 38 | } catch (DecryptionFailedException $e) { 39 | $this->assertSame('', $e->getData()); 40 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 41 | $this->assertSame('invalid iv length.', $e->getOriginalMessage()); 42 | } 43 | } 44 | 45 | public function testInvalidTagLength(): void 46 | { 47 | $cryptor = new FixedPasswordCryptor('password', new Cryptor('aes-256-gcm')); 48 | $this->assertFalse($cryptor->decrypt(str_repeat('x', 16))); 49 | 50 | try { 51 | $cryptor->mustDecrypt(str_repeat('x', 16)); 52 | $this->assertTrue(false); 53 | } catch (DecryptionFailedException $e) { 54 | $this->assertSame(str_repeat('x', 16), $e->getData()); 55 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 56 | $this->assertSame('invalid tag length.', $e->getOriginalMessage()); 57 | } 58 | } 59 | 60 | public function testInvalidTagContent(): void 61 | { 62 | $cryptor = new FixedPasswordCryptor('password', new Cryptor('aes-256-gcm')); 63 | 64 | $corrupted = substr_replace( 65 | $cryptor->encrypt(''), 66 | str_repeat('x', 16), 67 | -16 68 | ); 69 | 70 | $this->assertFalse($cryptor->decrypt($corrupted)); 71 | 72 | try { 73 | $cryptor->mustDecrypt($corrupted); 74 | $this->assertTrue(false); 75 | } catch (DecryptionFailedException $e) { 76 | $this->assertSame($corrupted, $e->getData()); 77 | $this->assertSame('Failed to decrypt.', $e->getMessage()); 78 | $this->assertSame('invalid tag content.', $e->getOriginalMessage()); 79 | } 80 | } 81 | } 82 | --------------------------------------------------------------------------------