Refactor creating encryption key

Hook key file generation to the installation command
This commit is contained in:
Jan Goralski
2024-10-24 16:56:05 +02:00
parent 1e823152a4
commit 7b79ffef1a
19 changed files with 211 additions and 29 deletions

2
.env
View File

@@ -43,3 +43,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=doctrine://default?queue_n
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###
SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/dev.key

View File

@@ -18,3 +18,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync://
###< symfony/messenger ###
MAILER_DSN=null://null
SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key

View File

@@ -19,3 +19,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync://
###< symfony/messenger ###
MAILER_DSN=null://null
SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key

View File

@@ -19,3 +19,5 @@ SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync://
###< symfony/messenger ###
MAILER_DSN=null://null
SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH=%kernel.project_dir%/config/encryption/test.key

View File

View File

@@ -0,0 +1 @@
31400500d6649581d6ac178bc41c92acc686dd869e6aa8665b4dad27f8921075e8cbf34059793bf9a0c603cd870f0433fb817afdb68bd75445111f27fe36a3252c8bd26fdbd82801568e9c657b022fd39edabff90518a2e04377e4e813bf3bf7d9411e6e

View File

@@ -1,7 +1,6 @@
parameters:
test_default_state_machine_adapter: 'symfony_workflow'
test_sylius_state_machine_adapter: '%env(string:default:test_default_state_machine_adapter:TEST_SYLIUS_STATE_MACHINE_ADAPTER)%'
env(SYLIUS_PAYMENT_ENCRYPTION_KEY): 3140050018f411b02c666895e0b163aba52854dbe7f10cd65487b5ca8df7e087253d8da7e9e3ca9629e1b91a015ab282075e0c07eed2199a037385e0f578eaf3f44a6d7bbee69aa6a6a02fbd0d2c1f9e9ffebfac0bd048b9acfb151581e88b5d69afe0f0
sylius_api:
enabled: true

View File

@@ -9,8 +9,6 @@ parameters:
database_driver: pdo_sqlite
database_path: "%kernel.project_dir%/var/db.sql"
kernel.api_bundle_path: '%kernel.project_dir%/../../'
env(SYLIUS_PAYMENT_ENCRYPTION_PASSWORD): 'test-password'
env(SYLIUS_PAYMENT_ENCRYPTION_SALT): '9cef0eca7b496271306caf3ef94c70be'
api_platform:
enable_swagger_ui: false

View File

@@ -46,6 +46,10 @@ final class InstallCommand extends Command
'command' => 'sylius:install:jwt-setup',
'message' => 'Configuring JWT token.',
],
[
'command' => 'sylius:payment:generate-key',
'message' => 'Generating payment encryption key.',
],
[
'command' => 'sylius:install:assets',
'message' => 'Installing assets.',

View File

@@ -5,8 +5,6 @@ parameters:
locale: en_US
database_driver: pdo_sqlite
database_path: "%kernel.project_dir%/var/db.sql"
env(SYLIUS_PAYMENT_ENCRYPTION_PASSWORD): 'test-password'
env(SYLIUS_PAYMENT_ENCRYPTION_SALT): '9cef0eca7b496271306caf3ef94c70be'
framework:
secret: "ch4mb3r0f5ecr3ts"

View File

@@ -4,33 +4,85 @@ declare(strict_types=1);
namespace Sylius\Bundle\PaymentBundle\Console\Command;
use ParagonIE\ConstantTime\Hex;
use ParagonIE\Halite\Alerts\CannotPerformOperation;
use ParagonIE\Halite\Alerts\InvalidKey;
use ParagonIE\Halite\KeyFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
#[AsCommand(
name: 'sylius:payment:generate-key',
description: 'Generate encryption key for Sylius payment encryption.',
description: 'Generate a key for Sylius payment encryption.',
)]
final class GenerateEncryptionKeyCommand extends Command
{
protected SymfonyStyle $io;
public function __construct(
private readonly Filesystem $filesystem,
private readonly string $keyPath,
) {
parent::__construct();
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Generating encryption key for Sylius payment encryption');
$this->io->writeln('Generating encryption key for Sylius payment encryption');
if (false === $input->getOption('overwrite') && $this->filesystem->exists($this->keyPath)) {
$this->io->writeln(sprintf('Key file "%s" already exists.', $this->keyPath));
$answer = $this->io->confirm('Do you want to overwrite it?', false);
if (false === $answer) {
$this->io->info('Key generation has been canceled');
return Command::SUCCESS;
}
}
try {
$output->writeln('Key: ' . KeyFactory::export(KeyFactory::generateEncryptionKey())->getString());
} catch (\TypeError) {
$output->writeln('Key could not be generated. Please, make sure that PHP supports libsodium');
$generatedKey = KeyFactory::generateEncryptionKey();
} catch (CannotPerformOperation|InvalidKey|\TypeError) {
$this->io->error('Key could not be generated. Please, make sure that PHP supports libsodium');
return Command::FAILURE;
}
$output->writeln('Remember to update your configuration with this key');
try {
$this->filesystem->mkdir(\dirname($this->keyPath));
$this->filesystem->touch($this->keyPath);
$saved = KeyFactory::save($generatedKey, $this->keyPath);
} catch (IOException) {
$saved = false;
}
if (false === $saved) {
$this->io->error(sprintf(
'Key could not be saved. Please, make sure that the directory "%s" is writable',
\dirname($this->keyPath),
));
return Command::FAILURE;
}
$this->io->success(sprintf('Key has been generated and saved in "%s"', $this->keyPath));
return Command::SUCCESS;
}
protected function configure(): void
{
$this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrites an existing key file');
}
}

View File

@@ -4,6 +4,9 @@
imports:
- { resource: "@SyliusPaymentBundle/Resources/config/app/messenger.yaml" }
parameters:
env(SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH): '%kernel.project_dir%/config/encryption/key'
sylius_payment:
payment_request:
states_to_be_cancelled_when_payment_method_changed:

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This file is part of the Sylius package.
(c) Sylius Sp. z o.o.
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<container
xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"
>
<services>
<defaults public="true" />
<service id="sylius.console.command.generate_encryption_key" class="Sylius\Bundle\PaymentBundle\Console\Command\GenerateEncryptionKeyCommand">
<argument type="service" id="filesystem" />
<argument>%env(resolve:SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH)%</argument>
<tag name="console.command" />
</service>
</services>
</container>

View File

@@ -18,7 +18,7 @@
>
<services>
<service id="sylius.encrypter" class="Sylius\Component\Payment\Encryption\Encrypter">
<argument>%env(SYLIUS_PAYMENT_ENCRYPTION_KEY)%</argument>
<argument>%env(resolve:SYLIUS_PAYMENT_ENCRYPTION_KEY_PATH)%</argument>
</service>
<service id="Sylius\Component\Payment\Encryption\EncrypterInterface" alias="sylius.encrypter" />
@@ -46,9 +46,5 @@
<tag name="doctrine.event_listener" event="postFlush" />
<tag name="doctrine.event_listener" event="postLoad" />
</service>
<service id="sylius.console.command.generate_encryption_salt" class="Sylius\Bundle\PaymentBundle\Console\Command\GenerateEncryptionKeyCommand">
<tag name="console.command" />
</service>
</services>
</container>

View File

@@ -13,30 +13,110 @@ declare(strict_types=1);
namespace Sylius\Bundle\PaymentBundle\Tests\Console\Command;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\PaymentBundle\Console\Command\GenerateEncryptionKeyCommand;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
final class GenerateEncryptionKeyCommandTest extends TestCase
final class GenerateEncryptionKeyCommandTest extends KernelTestCase
{
private const ENCRYPTION_KEY_PATH = __DIR__ . '/config/test.key';
private CommandTester $commandTester;
protected function setUp(): void
{
parent::setUp();
$kernel = static::createKernel();
$kernel->boot();
$this->commandTester = new CommandTester(new GenerateEncryptionKeyCommand());
$command = new GenerateEncryptionKeyCommand(new Filesystem(), self::ENCRYPTION_KEY_PATH);
$this->commandTester = new CommandTester($command);
}
/** @test */
public function it_generates_encryption_salt(): void
public function it_generates_and_saves_the_encryption_key_in_path(): void
{
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output);
$this->assertStringContainsString('Key:', $output);
$this->assertStringContainsString('Please, remember to update your configuration with this key', $output);
$this->assertStringContainsString('Key has been generated and saved in', $output);
$this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output));
}
/** @test */
public function it_does_not_overwrite_existing_key_when_it_is_not_requested(): void
{
$this->commandTester->setInputs(['Do you want to overwrite it?' => 'n']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output);
$this->assertStringContainsString('Do you want to overwrite it? (yes/no)', $output);
$this->assertStringContainsString(
$this->normalizeString(sprintf('"%s" already exists', self::ENCRYPTION_KEY_PATH)),
$this->normalizeString($output),
);
$this->assertStringContainsString('[INFO] Key generation has been canceled', $output);
}
/** @test */
public function it_overwrites_existing_key_when_requested(): void
{
$this->commandTester->setInputs(['Do you want to overwrite it?' => 'y']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output);
$this->assertStringContainsString('Do you want to overwrite it? (yes/no)', $output);
$this->assertStringContainsString(
$this->normalizeString(sprintf('"%s" already exists', self::ENCRYPTION_KEY_PATH)),
$this->normalizeString($output),
);
$this->assertStringContainsString('Key has been generated and saved in', $output);
$this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output));
}
/** @test */
public function it_automatically_overwrites_existing_key_when_overwrite_option_is_passed(): void
{
$this->commandTester->execute(['--overwrite' => true]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Generating encryption key for Sylius payment encryption', $output);
$this->assertStringContainsString('Key has been generated and saved in', $output);
$this->assertStringContainsString(self::ENCRYPTION_KEY_PATH, $this->normalizeString($output));
}
public static function tearDownAfterClass(): void
{
self::removeKey();
}
private static function removeKey(): void
{
if (file_exists(self::ENCRYPTION_KEY_PATH)) {
unlink(self::ENCRYPTION_KEY_PATH);
rmdir(dirname(self::ENCRYPTION_KEY_PATH));
}
}
private function normalizeString(string $string): string
{
return preg_replace('/\s+/', '', $string);
}
}

View File

@@ -13,7 +13,9 @@ declare(strict_types=1);
namespace Sylius\Component\Payment\Encryption;
use ParagonIE\Halite\Alerts\CannotPerformOperation;
use ParagonIE\Halite\Alerts\HaliteAlert;
use ParagonIE\Halite\Alerts\InvalidKey;
use ParagonIE\Halite\KeyFactory;
use ParagonIE\Halite\Symmetric\Crypto;
use ParagonIE\Halite\Symmetric\EncryptionKey;
@@ -30,7 +32,7 @@ final class Encrypter implements EncrypterInterface
private ?EncryptionKey $key = null;
public function __construct(
private readonly string $encryptionKey,
private readonly string $encryptionKeyPath,
) {
}
@@ -61,7 +63,11 @@ final class Encrypter implements EncrypterInterface
private function getKey(): EncryptionKey
{
if (null === $this->key) {
$this->key = KeyFactory::importEncryptionKey(new HiddenString($this->encryptionKey));
try {
$this->key = KeyFactory::loadEncryptionKey($this->encryptionKeyPath);
} catch (CannotPerformOperation|InvalidKey $exception) {
throw EncryptionException::invalidKey($exception);
}
}
return $this->key;

View File

@@ -31,4 +31,12 @@ final class EncryptionException extends \RuntimeException
previous: $previousException,
);
}
public static function invalidKey(\Throwable $previousException): self
{
return new self(
message: 'Invalid encryption key.',
previous: $previousException,
);
}
}

View File

@@ -21,7 +21,7 @@ final class EncrypterSpec extends ObjectBehavior
{
function let(): void
{
$this->beConstructedWith('a_very_strong_password', '6081e27f4be703ebe4626fb40c40cb2c');
$this->beConstructedWith(__DIR__ . '/fixtures/encryption_key');
}
function it_is_an_encrypter(): void
@@ -31,13 +31,13 @@ final class EncrypterSpec extends ObjectBehavior
function it_throws_an_exception_if_it_cannot_encrypt(): void
{
$this->beConstructedWith('', '');
$this->beConstructedWith('');
$this->shouldThrow(EncryptionException::class)->during('encrypt', ['data']);
}
function it_throws_an_exception_if_it_cannot_decrypt(): void
{
$this->beConstructedWith('', '');
$this->beConstructedWith('');
$this->shouldThrow(EncryptionException::class)->during('decrypt', ['data#ENCRYPTED']);
}

View File

@@ -0,0 +1 @@
31400500d37bed69c4fc80633efe2724978b6304197ba4bb15b895a36cc9ef2c833e0ca107738307224758566d75e5f3bf023329caaf360793f91f376ca5d0ac6bbe937e024a8b328ac4fd79649213537f7e377f6da41f10db8f8d7d5c2f52e80412e9f3