From 07e078f616f8d0bbf3bd4706b5313957c83f79ad Mon Sep 17 00:00:00 2001 From: vokomarov Date: Tue, 24 Sep 2024 21:25:58 +0300 Subject: [PATCH] Implement AES 256 GCM for sensitive data --- .../20230417.235038_0_encrypt_columns.php | 2 +- .../20240922.193320_0_re_encrypt_columns.php | 76 +++++++++++++++++++ .../Bootloader/EntityBehaviorBootloader.php | 4 +- app/src/Database/Encrypter/Encrypter.php | 52 ------------- .../Database/Encrypter/EncrypterInterface.php | 12 --- app/src/Database/GoogleAccount.php | 6 +- app/src/Database/Passkey.php | 6 +- .../Database/Typecast/EncryptedTypecast.php | 27 +++++-- app/src/Database/User.php | 8 +- app/src/Database/Wallet.php | 4 +- app/src/Repository/PasskeyRepository.php | 4 +- app/src/Repository/UserRepository.php | 4 +- app/src/Security/EncryptedEntityChecker.php | 2 +- app/src/Security/UniqueChecker.php | 2 +- app/src/Service/Encrypter/AES256ECB.php | 31 ++++++++ app/src/Service/Encrypter/AES256GCM.php | 56 ++++++++++++++ app/src/Service/Encrypter/Cipher.php | 32 ++++++++ app/src/Service/Encrypter/CipherInterface.php | 12 +++ app/src/Service/Encrypter/Encrypter.php | 45 +++++++++++ .../Service/Encrypter/EncrypterInterface.php | 12 +++ .../Typecast/EncryptedTypecastTest.php | 64 +++++++++++++++- .../Database/Typecast/JsonTypecastTest.php | 3 - tests/Feature/Security/UniqueCheckerTest.php | 2 +- .../Encrypter/EncrypterTest.php | 4 +- tests/Traits/InteractsWithDatabase.php | 2 +- 25 files changed, 372 insertions(+), 100 deletions(-) create mode 100644 app/migrations/20240922.193320_0_re_encrypt_columns.php delete mode 100644 app/src/Database/Encrypter/Encrypter.php delete mode 100644 app/src/Database/Encrypter/EncrypterInterface.php create mode 100644 app/src/Service/Encrypter/AES256ECB.php create mode 100644 app/src/Service/Encrypter/AES256GCM.php create mode 100644 app/src/Service/Encrypter/Cipher.php create mode 100644 app/src/Service/Encrypter/CipherInterface.php create mode 100644 app/src/Service/Encrypter/Encrypter.php create mode 100644 app/src/Service/Encrypter/EncrypterInterface.php rename tests/Feature/{Database => Service}/Encrypter/EncrypterTest.php (95%) diff --git a/app/migrations/20230417.235038_0_encrypt_columns.php b/app/migrations/20230417.235038_0_encrypt_columns.php index ccb56f3e..b1f2e07d 100644 --- a/app/migrations/20230417.235038_0_encrypt_columns.php +++ b/app/migrations/20230417.235038_0_encrypt_columns.php @@ -4,7 +4,7 @@ namespace App; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use Cycle\Migrations\Migration; class EncryptColumnsMigration extends Migration diff --git a/app/migrations/20240922.193320_0_re_encrypt_columns.php b/app/migrations/20240922.193320_0_re_encrypt_columns.php new file mode 100644 index 00000000..d769743c --- /dev/null +++ b/app/migrations/20240922.193320_0_re_encrypt_columns.php @@ -0,0 +1,76 @@ +database()->select(['user_id', 'account_id', 'picture_url', 'data'])->from('google_accounts')->fetchAll(); + + foreach ($items as $googleAccount) { + $this->database()->update('google_accounts', [ + 'account_id' => $this->convert($googleAccount['account_id']), + 'picture_url' => $this->convert($googleAccount['picture_url']), + 'data' => $this->convert($googleAccount['data']), + ], [ + 'user_id' => $googleAccount['user_id'] + ])->run(); + } + + $items = $this->database()->select(['id', 'name', 'data'])->from('passkeys')->fetchAll(); + + foreach ($items as $passkey) { + $this->database()->update('passkeys', [ + 'name' => $this->convert($passkey['name']), + 'data' => $this->convert($passkey['data']), + ], [ + 'id' => $passkey['id'] + ])->run(); + } + + $items = $this->database()->select(['id', 'name', 'last_name'])->from('users')->fetchAll(); + + foreach ($items as $user) { + $this->database()->update('users', [ + 'name' => $this->convert($user['name']), + 'last_name' => $this->convert($user['last_name']), + ], [ + 'id' => $user['id'] + ])->run(); + } + + $items = $this->database()->select(['id', 'name'])->from('wallets')->fetchAll(); + + foreach ($items as $wallet) { + $this->database()->update('wallets', [ + 'name' => $this->convert($wallet['name']), + ], [ + 'id' => $wallet['id'] + ])->run(); + } + } + + public function down(): void + { + // + } + + private function convert(string $value): string + { + return $this->encrypter->encrypt( + $this->encrypter->decrypt($value, Cipher::AES256ECB), + Cipher::AES256GCM, + ); + } +} diff --git a/app/src/Bootloader/EntityBehaviorBootloader.php b/app/src/Bootloader/EntityBehaviorBootloader.php index 30b56bce..9e21c03c 100644 --- a/app/src/Bootloader/EntityBehaviorBootloader.php +++ b/app/src/Bootloader/EntityBehaviorBootloader.php @@ -4,8 +4,8 @@ namespace App\Bootloader; -use App\Database\Encrypter\Encrypter; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\Encrypter; +use App\Service\Encrypter\EncrypterInterface; use Cycle\ORM\Transaction\CommandGeneratorInterface; use Cycle\ORM\Entity\Behavior\EventDrivenCommandGenerator; use Spiral\Boot\Bootloader\Bootloader; diff --git a/app/src/Database/Encrypter/Encrypter.php b/app/src/Database/Encrypter/Encrypter.php deleted file mode 100644 index bedafb93..00000000 --- a/app/src/Database/Encrypter/Encrypter.php +++ /dev/null @@ -1,52 +0,0 @@ -key = $this->config->getDbEncrypterKey(); - } - - public function encrypt(string $value): string - { - if (! $this->isEnabled()) { - return $value; - } - - $payload = openssl_encrypt($value, static::CIPHER, $this->key); - $payload !== false || throw new EncrypterException('Encryption unsuccessful: ' . openssl_error_string()); - - return $payload; - } - - public function decrypt(string $payload): string - { - if (! $this->isEnabled()) { - return $payload; - } - - $value = openssl_decrypt($payload, static::CIPHER, $this->key); - - if ($value === false) { - throw new EncrypterException('Decryption unsuccessful: ' . openssl_error_string()); - } - - return $value; - } - - private function isEnabled(): bool - { - return !empty($this->key); - } -} diff --git a/app/src/Database/Encrypter/EncrypterInterface.php b/app/src/Database/Encrypter/EncrypterInterface.php deleted file mode 100644 index 19714cb4..00000000 --- a/app/src/Database/Encrypter/EncrypterInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - Cipher::AES256ECB, + self::STORE => Cipher::AES256GCM, + ]; private array $rules = []; @@ -29,7 +41,7 @@ public function __construct( public function setRules(array $rules): array { foreach ($rules as $key => $rule) { - if ($rule !== self::RULE) { + if ($rule !== self::QUERY && $rule !== self::STORE) { continue; } @@ -49,7 +61,7 @@ public function cast(array $data): array } try { - $data[$column] = $this->encrypter->decrypt($data[$column]); + $data[$column] = $this->encrypter->decrypt($data[$column], $this->getCipherByRule($rule)); } catch (EncrypterException $exception) { $original = $data[$column]; @@ -78,7 +90,7 @@ public function uncast(array $data): array } try { - $data[$column] = $this->encrypter->encrypt($data[$column]); + $data[$column] = $this->encrypter->encrypt($data[$column], $this->getCipherByRule($rule)); } catch (EncrypterException $exception) { $this->logger->warning('Unable to encrypt database column', [ 'column' => $column, @@ -89,4 +101,9 @@ public function uncast(array $data): array return $data; } + + protected function getCipherByRule(string $rule): ?Cipher + { + return static::CIPHERS[$rule] ?? null; + } } diff --git a/app/src/Database/User.php b/app/src/Database/User.php index a802b953..404f259f 100644 --- a/app/src/Database/User.php +++ b/app/src/Database/User.php @@ -28,16 +28,16 @@ class User implements PasswordContainerInterface #[ORM\Column('primary')] public int|null $id = null; - #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::STORE)] public string $name = ''; - #[ORM\Column(type: 'string(1536)', name: 'last_name', nullable: true, typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(1536)', name: 'last_name', nullable: true, typecast: EncryptedTypecast::STORE)] public string|null $lastName = null; - #[ORM\Column(type: 'string(767)', name: 'nick_name', typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(767)', name: 'nick_name', typecast: EncryptedTypecast::QUERY)] public string $nickName = ''; - #[ORM\Column(type: 'string(676)', typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(676)', typecast: EncryptedTypecast::QUERY)] public string $email = ''; #[ORM\Column(type: 'boolean', name: 'is_email_confirmed', default: false)] diff --git a/app/src/Database/Wallet.php b/app/src/Database/Wallet.php index f522e8e1..5a1cbf56 100644 --- a/app/src/Database/Wallet.php +++ b/app/src/Database/Wallet.php @@ -24,10 +24,10 @@ class Wallet implements Sortable #[ORM\Column('primary')] public int|null $id = null; - #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::STORE)] public string $name = ''; - #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::RULE)] + #[ORM\Column(type: 'string(1536)', typecast: EncryptedTypecast::QUERY)] public string $slug = ''; #[ORM\Column(type: 'decimal(13,2)', name: 'total_amount', default: 0.0)] diff --git a/app/src/Repository/PasskeyRepository.php b/app/src/Repository/PasskeyRepository.php index 2423b803..bb4bace5 100644 --- a/app/src/Repository/PasskeyRepository.php +++ b/app/src/Repository/PasskeyRepository.php @@ -4,7 +4,7 @@ namespace App\Repository; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use Cycle\ORM\Select; use Cycle\ORM\Select\Repository; use ParagonIE\ConstantTime\Base64UrlSafe; @@ -17,7 +17,7 @@ class PasskeyRepository extends Repository { /** * @param \Cycle\ORM\Select<\App\Database\Passkey> $select - * @param \App\Database\Encrypter\EncrypterInterface $encrypter + * @param \App\Service\Encrypter\EncrypterInterface $encrypter */ public function __construct( Select $select, diff --git a/app/src/Repository/UserRepository.php b/app/src/Repository/UserRepository.php index 7625ef52..0fcfd501 100644 --- a/app/src/Repository/UserRepository.php +++ b/app/src/Repository/UserRepository.php @@ -4,7 +4,7 @@ namespace App\Repository; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use App\Database\User; use Cycle\ORM\Select; use Cycle\ORM\Select\Repository; @@ -19,7 +19,7 @@ class UserRepository extends Repository implements ActorProviderInterface { /** * @param \Cycle\ORM\Select $select - * @param \App\Database\Encrypter\EncrypterInterface $encrypter + * @param \App\Service\Encrypter\EncrypterInterface $encrypter */ public function __construct( Select $select, diff --git a/app/src/Security/EncryptedEntityChecker.php b/app/src/Security/EncryptedEntityChecker.php index a61403ff..b00bfb91 100644 --- a/app/src/Security/EncryptedEntityChecker.php +++ b/app/src/Security/EncryptedEntityChecker.php @@ -4,7 +4,7 @@ namespace App\Security; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use Cycle\ORM\ORMInterface; use Spiral\Core\Attribute\Singleton; use Spiral\Cycle\Validation\EntityChecker; diff --git a/app/src/Security/UniqueChecker.php b/app/src/Security/UniqueChecker.php index 19b2ac63..39f547d5 100644 --- a/app/src/Security/UniqueChecker.php +++ b/app/src/Security/UniqueChecker.php @@ -4,7 +4,7 @@ namespace App\Security; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use Cycle\ORM\ORMInterface; use Spiral\Validator\AbstractChecker; diff --git a/app/src/Service/Encrypter/AES256ECB.php b/app/src/Service/Encrypter/AES256ECB.php new file mode 100644 index 00000000..90e890d9 --- /dev/null +++ b/app/src/Service/Encrypter/AES256ECB.php @@ -0,0 +1,31 @@ +value) || throw new RuntimeException("Undefined cipher class {$this->value}"); + + $instance = new $this->value; + + $instance instanceof CipherInterface || throw new RuntimeException('Cipher must be instance of ' . CipherInterface::class); + + return $instance; + } +} +// @codingStandardsIgnoreEnd diff --git a/app/src/Service/Encrypter/CipherInterface.php b/app/src/Service/Encrypter/CipherInterface.php new file mode 100644 index 00000000..1ff182d2 --- /dev/null +++ b/app/src/Service/Encrypter/CipherInterface.php @@ -0,0 +1,12 @@ +key = $this->appConfig->getDbEncrypterKey(); + } + + public function encrypt(string $value, Cipher $cipher = null): string + { + if (! $this->isEnabled()) { + return $value; + } + + return $this->getCipherInstance($cipher)->encrypt($value, $this->key); + } + + public function decrypt(string $payload, Cipher $cipher = null): string + { + if (! $this->isEnabled()) { + return $payload; + } + + return $this->getCipherInstance($cipher)->decrypt($payload, $this->key); + } + + private function isEnabled(): bool + { + return !empty($this->key); + } + + private function getCipherInstance(Cipher $cipher = null): CipherInterface + { + return ($cipher ?? Cipher::default())->getInstance(); + } +} diff --git a/app/src/Service/Encrypter/EncrypterInterface.php b/app/src/Service/Encrypter/EncrypterInterface.php new file mode 100644 index 00000000..43328f26 --- /dev/null +++ b/app/src/Service/Encrypter/EncrypterInterface.php @@ -0,0 +1,12 @@ +getContainer()->get(LoggerInterface::class), $encrypter); $typecast->setRules([ - 'column' => EncryptedTypecast::RULE, + 'column' => EncryptedTypecast::QUERY, 'other' => 'string', ]); @@ -52,7 +54,7 @@ public function testUncastException(): void $typecast = new EncryptedTypecast($this->getContainer()->get(LoggerInterface::class), $encrypter); $typecast->setRules([ - 'column' => EncryptedTypecast::RULE, + 'column' => EncryptedTypecast::QUERY, ]); $data = $typecast->uncast([ @@ -69,4 +71,60 @@ public function testUncastException(): void $this->assertArrayHasKey('column', $data); $this->assertEmpty($data['column']); } + + public function testUncastQueryEqual(): void + { + $key = Fixtures::string(); + + $config = $this->getMockBuilder(AppConfig::class)->onlyMethods(['getDbEncrypterKey'])->getMock(); + $config->method('getDbEncrypterKey')->willReturn($key); + $this->getContainer()->bind(AppConfig::class, $config); + + /** @var \App\Database\Typecast\EncryptedTypecast $typecast */ + $typecast = $this->getContainer()->get(EncryptedTypecast::class); + $typecast->setRules([ + 'column' => EncryptedTypecast::QUERY, + 'other' => 'string', + ]); + + $data = $typecast->uncast(['column' => 'data']); + + $this->assertArrayHasKey('column', $data); + $this->assertNotEmpty($data['column']); + + $dataNew = $typecast->uncast(['column' => 'data']); + + $this->assertArrayHasKey('column', $dataNew); + $this->assertNotEmpty($dataNew['column']); + + $this->assertEquals($dataNew['column'], $data['column']); + } + + public function testUncastStoreDifferent(): void + { + $key = Fixtures::string(); + + $config = $this->getMockBuilder(AppConfig::class)->onlyMethods(['getDbEncrypterKey'])->getMock(); + $config->method('getDbEncrypterKey')->willReturn($key); + $this->getContainer()->bind(AppConfig::class, $config); + + /** @var \App\Database\Typecast\EncryptedTypecast $typecast */ + $typecast = $this->getContainer()->get(EncryptedTypecast::class); + $typecast->setRules([ + 'column' => EncryptedTypecast::STORE, + 'other' => 'string', + ]); + + $data = $typecast->uncast(['column' => 'data']); + + $this->assertArrayHasKey('column', $data); + $this->assertNotEmpty($data['column']); + + $dataNew = $typecast->uncast(['column' => 'data']); + + $this->assertArrayHasKey('column', $dataNew); + $this->assertNotEmpty($dataNew['column']); + + $this->assertNotEquals($dataNew['column'], $data['column']); + } } diff --git a/tests/Feature/Database/Typecast/JsonTypecastTest.php b/tests/Feature/Database/Typecast/JsonTypecastTest.php index 64d8b598..42404104 100644 --- a/tests/Feature/Database/Typecast/JsonTypecastTest.php +++ b/tests/Feature/Database/Typecast/JsonTypecastTest.php @@ -4,11 +4,8 @@ namespace Tests\Feature\Database\Typecast; -use App\Database\Encrypter\EncrypterInterface; -use App\Database\Typecast\EncryptedTypecast; use App\Database\Typecast\JsonTypecast; use Psr\Log\LoggerInterface; -use Spiral\Encrypter\Exception\EncrypterException; use Tests\TestCase; class JsonTypecastTest extends TestCase diff --git a/tests/Feature/Security/UniqueCheckerTest.php b/tests/Feature/Security/UniqueCheckerTest.php index 011e155a..e14eb91d 100644 --- a/tests/Feature/Security/UniqueCheckerTest.php +++ b/tests/Feature/Security/UniqueCheckerTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\Security; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use App\Security\UniqueChecker; use Cycle\ORM\ORMInterface; use Tests\Fixtures; diff --git a/tests/Feature/Database/Encrypter/EncrypterTest.php b/tests/Feature/Service/Encrypter/EncrypterTest.php similarity index 95% rename from tests/Feature/Database/Encrypter/EncrypterTest.php rename to tests/Feature/Service/Encrypter/EncrypterTest.php index 2f6bb0f1..262cc66c 100644 --- a/tests/Feature/Database/Encrypter/EncrypterTest.php +++ b/tests/Feature/Service/Encrypter/EncrypterTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Feature\Database\Encrypter; +namespace Tests\Feature\Service\Encrypter; use App\Config\AppConfig; -use App\Database\Encrypter\Encrypter; +use App\Service\Encrypter\Encrypter; use Spiral\Encrypter\Exception\EncrypterException; use Tests\Fixtures; use Tests\TestCase; diff --git a/tests/Traits/InteractsWithDatabase.php b/tests/Traits/InteractsWithDatabase.php index c1dee893..989a725f 100644 --- a/tests/Traits/InteractsWithDatabase.php +++ b/tests/Traits/InteractsWithDatabase.php @@ -4,7 +4,7 @@ namespace Tests\Traits; -use App\Database\Encrypter\EncrypterInterface; +use App\Service\Encrypter\EncrypterInterface; use Cycle\Database\DatabaseInterface; trait InteractsWithDatabase