From 8c8f62390a18116ee681ed2a66d36971a9c19947 Mon Sep 17 00:00:00 2001 From: Tobias Schultze Date: Sat, 6 Apr 2019 19:40:45 +0200 Subject: [PATCH 1/6] Proof of concept for encrypted secrets --- .../Command/SecretsAddCommand.php | 68 ++++++++++++++++ .../Command/SecretsGenerateKeyCommand.php | 28 +++++++ .../Command/SecretsListCommand.php | 45 +++++++++++ .../DependencyInjection/Configuration.php | 19 +++++ .../FrameworkExtension.php | 15 ++++ .../Resources/config/console.xml | 14 ++++ .../Resources/config/secrets.xml | 23 ++++++ .../Secret/CachedSecretStorage.php | 78 +++++++++++++++++++ .../Secret/EncryptedMessage.php | 49 ++++++++++++ .../Secret/FilesSecretStorage.php | 69 ++++++++++++++++ .../Secret/SecretEnvVarProcessor.php | 42 ++++++++++ .../Secret/SecretStorageInterface.php | 14 ++++ 12 files changed, 464 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php new file mode 100644 index 000000000000..780d4c2373df --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php @@ -0,0 +1,68 @@ +secretStorage = $secretStorage; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Adds a secret with the key.') + ->addArgument( + 'key', + InputArgument::REQUIRED + ) + ->addArgument( + 'secret', + InputArgument::REQUIRED + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $key = $input->getArgument('key'); + $secret = $input->getArgument('secret'); + + $this->secretStorage->putSecret($key, $secret); + } + + protected function interact(InputInterface $input, OutputInterface $output) + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new Question('Key of the secret: ', $input->getArgument('key')); + + $key = $helper->ask($input, $output, $question); + $input->setArgument('key', $key); + + $question = new Question('Plaintext secret value: ', $input->getArgument('secret')); + $question->setHidden(true); + + $secret = $helper->ask($input, $output, $question); + $input->setArgument('secret', $secret); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php new file mode 100644 index 000000000000..fd1d1a8fae9f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php @@ -0,0 +1,28 @@ +setDescription('Prints a randomly generated encryption key.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $encryptionKey = sodium_crypto_stream_keygen(); + + $output->write($encryptionKey, false, OutputInterface::OUTPUT_RAW); + + sodium_memzero($encryptionKey); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php new file mode 100644 index 000000000000..c280acd6b166 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -0,0 +1,45 @@ +secretStorage = $secretStorage; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Lists all secrets.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $table = new Table($output); + $table->setHeaders(['key', 'plaintext secret']); + + foreach ($this->secretStorage->listSecrets() as $key => $secret) { + $table->addRow([$key, $secret]); + } + + $table->render(); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index c34377fa0de1..fd8901c80472 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -115,10 +115,29 @@ public function getConfigTreeBuilder() $this->addRobotsIndexSection($rootNode); $this->addHttpClientSection($rootNode); $this->addMailerSection($rootNode); + $this->addSecretsSection($rootNode); return $treeBuilder; } + private function addSecretsSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('secrets') + ->canBeEnabled() + ->children() + ->scalarNode('encrypted_secrets_dir')->end() + ->scalarNode('encryption_key')->end() + //->scalarNode('public_key')->end() + //->scalarNode('private_key')->end() + ->scalarNode('decrypted_secrets_cache')->end() + ->end() + ->end() + ->end() + ; + } + private function addCsrfSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 1d83148ff755..97486af068d0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -334,6 +334,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerRouterConfiguration($config['router'], $container, $loader); $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); + $this->registerSecretsConfiguration($config['secrets'], $container, $loader); if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists('Symfony\Component\Serializer\Serializer')) { @@ -1441,6 +1442,20 @@ private function registerPropertyAccessConfiguration(array $config, ContainerBui ; } + private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!$this->isConfigEnabled($container, $config)) { + $container->removeDefinition('console.command.secrets_add'); + + return; + } + + $loader->load('secrets.xml'); + + $container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']); + $container->getDefinition('secrets.storage.files')->replaceArgument(1, $config['encryption_key']); + } + private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { if (!$this->isConfigEnabled($container, $config)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index f13aa759d31c..a4249d1797de 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -200,5 +200,19 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml new file mode 100644 index 000000000000..8bdb2422ec0a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php new file mode 100644 index 000000000000..3fe9ff7184aa --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php @@ -0,0 +1,78 @@ +decoratedStorage = $decoratedStorage; + $this->cache = $cache; + } + + public function getSecret(string $key): string + { + $cacheItem = $this->cache->getItem('secrets.php'); + + if ($cacheItem->isHit()) { + $secrets = $cacheItem->get(); + if (isset($secrets[$key])) { + return $secrets[$key]; + } + } + + $this->regenerateCache($cacheItem); + + return $this->decoratedStorage->getSecret($key); + } + + public function putSecret(string $key, string $secret): void + { + $this->decoratedStorage->putSecret($key, $secret); + $this->regenerateCache(); + } + + public function deleteSecret(string $key): void + { + $this->decoratedStorage->deleteSecret($key); + $this->regenerateCache(); + } + + public function listSecrets(): iterable + { + $cacheItem = $this->cache->getItem('secrets.php'); + + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + + return $this->regenerateCache($cacheItem); + } + + private function regenerateCache(?CacheItemInterface $cacheItem = null): array + { + $cacheItem = $cacheItem ?? $this->cache->getItem('secrets.php'); + + $secrets = []; + foreach ($this->decoratedStorage->listSecrets() as $key => $secret) { + $secrets[$key] = $secret; + } + + $cacheItem->set($secrets); + $this->cache->save($cacheItem); + + return $secrets; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php new file mode 100644 index 000000000000..72a40724db77 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php @@ -0,0 +1,49 @@ +ciphertext = $ciphertext; + $this->nonce = $nonce; + } + + public function __toString() + { + return $this->nonce.$this->ciphertext; + } + + public function getCiphertext(): string + { + return $this->ciphertext; + } + + public function getNonce(): string + { + return $this->nonce; + } + + public static function createFromString(string $message): self + { + if (\strlen($message) < SODIUM_CRYPTO_STREAM_NONCEBYTES) { + throw new \RuntimeException('Invalid ciphertext. Message is too short.'); + } + + $nonce = substr($message, 0, SODIUM_CRYPTO_STREAM_NONCEBYTES); + $ciphertext = substr($message, SODIUM_CRYPTO_STREAM_NONCEBYTES); + + return new self($ciphertext, $nonce); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php new file mode 100644 index 000000000000..1eebc3920a4a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php @@ -0,0 +1,69 @@ +secretsFolder = $secretsFolder; + $this->encryptionKey = $encryptionKey; + } + + public function getSecret(string $key): string + { + return $this->decryptFile($this->getFilePath($key)); + } + + public function putSecret(string $key, string $secret): void + { + $nonce = random_bytes(SODIUM_CRYPTO_STREAM_NONCEBYTES); + $ciphertext = sodium_crypto_stream_xor($secret, $nonce, $this->encryptionKey); + + sodium_memzero($secret); + + $message = new EncryptedMessage($ciphertext, $nonce); + + file_put_contents($this->getFilePath($key), (string) $message); + } + + public function deleteSecret(string $key): void + { + unlink($this->getFilePath($key)); + } + + public function listSecrets(): iterable + { + foreach (scandir($this->secretsFolder) as $fileName) { + if ('.' === $fileName || '..' === $fileName) { + continue; + } + + $key = basename($fileName, '.bin'); + yield $key => $this->getSecret($key); + } + } + + private function decryptFile(string $filePath): string + { + $encrypted = file_get_contents($filePath); + + $message = EncryptedMessage::createFromString($encrypted); + + return sodium_crypto_stream_xor($message->getCiphertext(), $message->getNonce(), $this->encryptionKey); + } + + private function getFilePath(string $key): string + { + return $this->secretsFolder.$key.'.bin'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php new file mode 100644 index 000000000000..17e5eb36646a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret; + +use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; + +class SecretEnvVarProcessor implements EnvVarProcessorInterface +{ + private $secretStorage; + + public function __construct(SecretStorageInterface $secretStorage) + { + $this->secretStorage = $secretStorage; + } + + /** + * {@inheritdoc} + */ + public static function getProvidedTypes() + { + return [ + 'secret' => 'string', + ]; + } + + /** + * {@inheritdoc} + */ + public function getEnv($prefix, $name, \Closure $getEnv) + { + return $this->secretStorage->getSecret($name); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php new file mode 100644 index 000000000000..a57a11eb8ce7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php @@ -0,0 +1,14 @@ + Date: Mon, 8 Apr 2019 20:52:56 +0200 Subject: [PATCH 2/6] Add secrets management --- .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../Command/SecretsAddCommand.php | 72 ++++++------ .../Command/SecretsGenerateKeyCommand.php | 77 +++++++++++- .../Command/SecretsListCommand.php | 69 +++++++++-- .../Command/SecretsRemoveCommand.php | 61 ++++++++++ .../DependencyInjection/Configuration.php | 7 +- .../FrameworkExtension.php | 11 +- .../EncryptionKeyNotFoundException.php | 28 +++++ .../Exception/SecretNotFoundException.php | 28 +++++ .../Resources/config/console.xml | 11 +- .../Resources/config/secrets.xml | 15 ++- .../Secret/CachedSecretStorage.php | 78 ------------- .../Secret/Encoder/EncoderInterface.php | 39 +++++++ .../Secret/Encoder/SodiumEncoder.php | 110 ++++++++++++++++++ .../Secret/EncryptedMessage.php | 49 -------- .../Secret/FilesSecretStorage.php | 69 ----------- .../Secret/SecretEnvVarProcessor.php | 12 +- .../Secret/SecretStorageInterface.php | 14 --- .../Secret/Storage/CachedSecretStorage.php | 48 ++++++++ .../Secret/Storage/ChainSecretStorage.php | 58 +++++++++ .../Secret/Storage/FilesSecretStorage.php | 97 +++++++++++++++ .../Storage/MutableSecretStorageInterface.php | 30 +++++ .../Secret/Storage/SecretStorageInterface.php | 38 ++++++ .../DependencyInjection/ConfigurationTest.php | 5 + .../Secret/Encoder/SodiumEncoderTest.php | 71 +++++++++++ .../Secret/Storage/ChainSecretStorageTest.php | 51 ++++++++ .../Secret/Storage/FilesSecretStorageTest.php | 74 ++++++++++++ 27 files changed, 951 insertions(+), 272 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/CachedSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 093f4bb1da1a..1d093df8ffaf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -17,6 +17,7 @@ CHANGELOG * Added new `error_controller` configuration to handle system exceptions * Added sort option for `translation:update` command. * [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore. + * Added secrets management. 4.3.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php index 780d4c2373df..4d2d9d27aed6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php @@ -1,27 +1,37 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Bundle\FrameworkBundle\Command; -use Symfony\Bundle\FrameworkBundle\Secret\SecretStorageInterface; +use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; +use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ final class SecretsAddCommand extends Command { protected static $defaultName = 'secrets:add'; - /** - * @var SecretStorageInterface - */ - private $secretStorage; + private $secretsStorage; - public function __construct(SecretStorageInterface $secretStorage) + public function __construct(MutableSecretStorageInterface $secretsStorage) { - $this->secretStorage = $secretStorage; + $this->secretsStorage = $secretsStorage; parent::__construct(); } @@ -29,40 +39,32 @@ public function __construct(SecretStorageInterface $secretStorage) protected function configure() { $this - ->setDescription('Adds a secret with the key.') - ->addArgument( - 'key', - InputArgument::REQUIRED - ) - ->addArgument( - 'secret', - InputArgument::REQUIRED + ->setDefinition([ + new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'), + ]) + ->setDescription('Adds a secret in the storage.') + ->setHelp(<<<'EOF' +The %command.name% command stores a secret. + + %command.full_name% +EOF ) ; } protected function execute(InputInterface $input, OutputInterface $output) { - $key = $input->getArgument('key'); - $secret = $input->getArgument('secret'); - - $this->secretStorage->putSecret($key, $secret); - } - - protected function interact(InputInterface $input, OutputInterface $output) - { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - - $question = new Question('Key of the secret: ', $input->getArgument('key')); + $io = new SymfonyStyle($input, $output); - $key = $helper->ask($input, $output, $question); - $input->setArgument('key', $key); + $name = $input->getArgument('name'); + $secret = $io->askHidden('Value of the secret'); - $question = new Question('Plaintext secret value: ', $input->getArgument('secret')); - $question->setHidden(true); + try { + $this->secretsStorage->setSecret($name, $secret); + } catch (EncryptionKeyNotFoundException $e) { + throw new \LogicException(sprintf('No encryption keys found. You should call the "%s" command.', SecretsGenerateKeyCommand::getDefaultName())); + } - $secret = $helper->ask($input, $output, $question); - $input->setArgument('secret', $secret); + $io->success('Secret was successfully stored.'); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php index fd1d1a8fae9f..d443c404bf77 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php @@ -1,28 +1,97 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Bundle\FrameworkBundle\Command; +use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; +use Symfony\Bundle\FrameworkBundle\Secret\Encoder\EncoderInterface; +use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; 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; +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ final class SecretsGenerateKeyCommand extends Command { protected static $defaultName = 'secrets:generate-key'; + private $secretsStorage; + private $encoder; + + public function __construct(EncoderInterface $encoder, MutableSecretStorageInterface $secretsStorage) + { + $this->secretsStorage = $secretsStorage; + $this->encoder = $encoder; + parent::__construct(); + } protected function configure() { $this - ->setDescription('Prints a randomly generated encryption key.') + ->setDefinition([ + new InputOption('rekey', 'r', InputOption::VALUE_NONE, 'Re-encrypt previous secret with the new key.'), + ]) + ->setDescription('Generates a new encryption key.') + ->setHelp(<<<'EOF' +The %command.name% command generates a new encryption key. + + %command.full_name% + +If a previous encryption key already exists, the command must be called with +the --rekey option in order to override that key and re-encrypt +previous secrets. + + %command.full_name% --rekey +EOF + ) ; } protected function execute(InputInterface $input, OutputInterface $output) { - $encryptionKey = sodium_crypto_stream_keygen(); + $rekey = $input->getOption('rekey'); + + $previousSecrets = []; + try { + foreach ($this->secretsStorage->listSecrets(true) as $name => $decryptedSecret) { + $previousSecrets[$name] = $decryptedSecret; + } + } catch (EncryptionKeyNotFoundException $e) { + if (!$rekey) { + throw $e; + } + } - $output->write($encryptionKey, false, OutputInterface::OUTPUT_RAW); + $keys = $this->encoder->generateKeys($rekey); + foreach ($previousSecrets as $name => $decryptedSecret) { + $this->secretsStorage->setSecret($name, $decryptedSecret); + } - sodium_memzero($encryptionKey); + $io = new SymfonyStyle($input, $output); + switch (\count($keys)) { + case 0: + $io->success('Keys have been generated.'); + break; + case 1: + $io->success(sprintf('A key has been generated in "%s".', $keys[0])); + $io->caution('DO NOT COMMIT that file!'); + break; + default: + $io->success(sprintf("Keys have been generated in :\n -%s", implode("\n -", $keys))); + $io->caution('DO NOT COMMIT those files!'); + break; + } } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php index c280acd6b166..09864eb741d1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -1,20 +1,32 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Bundle\FrameworkBundle\Command; -use Symfony\Bundle\FrameworkBundle\Secret\SecretStorageInterface; +use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; +use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ final class SecretsListCommand extends Command { - protected static $defaultName = 'secrets:list'; + protected static $defaultName = 'debug:secrets'; - /** - * @var SecretStorageInterface - */ private $secretStorage; public function __construct(SecretStorageInterface $secretStorage) @@ -27,19 +39,54 @@ public function __construct(SecretStorageInterface $secretStorage) protected function configure() { $this + ->setDefinition([ + new InputOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names'), + ]) ->setDescription('Lists all secrets.') + ->setHelp(<<<'EOF' +The %command.name% command list all stored secrets. + + %command.full_name% + +When the the option --reveal is provided, the decrypted secrets are also displayed. + + %command.full_name% --reveal +EOF + ) ; + + $this + ->setDescription('Lists all secrets.') + ->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names'); } protected function execute(InputInterface $input, OutputInterface $output) { - $table = new Table($output); - $table->setHeaders(['key', 'plaintext secret']); + $reveal = $input->getOption('reveal'); + $io = new SymfonyStyle($input, $output); + + try { + $secrets = $this->secretStorage->listSecrets($reveal); + } catch (EncryptionKeyNotFoundException $e) { + throw new \LogicException(sprintf('Unable to decrypt secrets, the encryption key "%s" is missing.', $e->getKeyLocation())); + } + + if ($reveal) { + $rows = []; + foreach ($secrets as $name => $value) { + $rows[] = [$name, $value]; + } + $io->table(['name', 'secret'], $rows); + + return; + } - foreach ($this->secretStorage->listSecrets() as $key => $secret) { - $table->addRow([$key, $secret]); + $rows = []; + foreach ($secrets as $name => $_) { + $rows[] = [$name]; } - $table->render(); + $io->comment(sprintf('To reveal the values of the secrets use php %s %s --reveal', $_SERVER['PHP_SELF'], $this->getName())); + $io->table(['name'], $rows); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php new file mode 100644 index 000000000000..173166b05be9 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Jérémy Derussé + */ +final class SecretsRemoveCommand extends Command +{ + protected static $defaultName = 'secrets:remove'; + + private $secretsStorage; + + public function __construct(MutableSecretStorageInterface $secretsStorage) + { + $this->secretsStorage = $secretsStorage; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDefinition([ + new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'), + ]) + ->setDescription('Removes a secret from the storage.') + ->setHelp(<<<'EOF' +The %command.name% command remove a secret. + + %command.full_name% +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $this->secretsStorage->removeSecret($input->getArgument('name')); + + $io->success('Secret was successfully removed.'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index fd8901c80472..7730bef5156c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -127,11 +127,8 @@ private function addSecretsSection(ArrayNodeDefinition $rootNode) ->arrayNode('secrets') ->canBeEnabled() ->children() - ->scalarNode('encrypted_secrets_dir')->end() - ->scalarNode('encryption_key')->end() - //->scalarNode('public_key')->end() - //->scalarNode('private_key')->end() - ->scalarNode('decrypted_secrets_cache')->end() + ->scalarNode('encrypted_secrets_dir')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end() + ->scalarNode('encryption_key')->defaultValue('%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key')->cannotBeEmpty()->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 97486af068d0..8b5aea7b260d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Bundle\FrameworkBundle\Routing\RedirectableUrlMatcher; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; +use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; use Symfony\Bundle\FullStack; use Symfony\Component\Asset\PackageInterface; use Symfony\Component\BrowserKit\AbstractBrowser; @@ -1446,14 +1447,22 @@ private function registerSecretsConfiguration(array $config, ContainerBuilder $c { if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition('console.command.secrets_add'); + $container->removeDefinition('console.command.secrets_list'); + $container->removeDefinition('console.command.secrets_remove'); + $container->removeDefinition('console.command.secrets_generate_key'); return; } $loader->load('secrets.xml'); + $container->setAlias(SecretStorageInterface::class, new Alias('secrets.storage.cache', false)); + $container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']); - $container->getDefinition('secrets.storage.files')->replaceArgument(1, $config['encryption_key']); + $container->getDefinition('secrets.encoder.sodium')->replaceArgument(0, $config['encryption_key']); + + $container->registerForAutoconfiguration(SecretStorageInterface::class) + ->addTag('secret_storage'); } private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php new file mode 100644 index 000000000000..be2592c86c5a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Exception; + +class EncryptionKeyNotFoundException extends \RuntimeException +{ + private $keyLocation; + + public function __construct(string $keyLocation) + { + $this->keyLocation = $keyLocation; + parent::__construct(sprintf('Encryption key not found in "%s".', $keyLocation)); + } + + public function getKeyLocation(): string + { + return $this->keyLocation; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php new file mode 100644 index 000000000000..ac63dc4775b6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Exception; + +class SecretNotFoundException extends \RuntimeException +{ + private $name; + + public function __construct(string $name) + { + $this->name = $name; + parent::__construct(sprintf('The secret "%s" does not exist.', $name)); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index a4249d1797de..e5cb8e4c4b63 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -202,17 +202,24 @@ - + + + + + + + + - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml index 8bdb2422ec0a..4989dd99734e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -5,13 +5,22 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd"> - + + + + + + + + + + - - + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php deleted file mode 100644 index 3fe9ff7184aa..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php +++ /dev/null @@ -1,78 +0,0 @@ -decoratedStorage = $decoratedStorage; - $this->cache = $cache; - } - - public function getSecret(string $key): string - { - $cacheItem = $this->cache->getItem('secrets.php'); - - if ($cacheItem->isHit()) { - $secrets = $cacheItem->get(); - if (isset($secrets[$key])) { - return $secrets[$key]; - } - } - - $this->regenerateCache($cacheItem); - - return $this->decoratedStorage->getSecret($key); - } - - public function putSecret(string $key, string $secret): void - { - $this->decoratedStorage->putSecret($key, $secret); - $this->regenerateCache(); - } - - public function deleteSecret(string $key): void - { - $this->decoratedStorage->deleteSecret($key); - $this->regenerateCache(); - } - - public function listSecrets(): iterable - { - $cacheItem = $this->cache->getItem('secrets.php'); - - if ($cacheItem->isHit()) { - return $cacheItem->get(); - } - - return $this->regenerateCache($cacheItem); - } - - private function regenerateCache(?CacheItemInterface $cacheItem = null): array - { - $cacheItem = $cacheItem ?? $this->cache->getItem('secrets.php'); - - $secrets = []; - foreach ($this->decoratedStorage->listSecrets() as $key => $secret) { - $secrets[$key] = $secret; - } - - $cacheItem->set($secrets); - $this->cache->save($cacheItem); - - return $secrets; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php new file mode 100644 index 000000000000..c1fe5e6bc3cd --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Encoder; + +/** + * EncoderInterface defines an interface to encrypt and decrypt secrets. + * + * @author Jérémy Derussé + */ +interface EncoderInterface +{ + /** + * Generate the keys and material necessary for its operation. + * + * @param bool $override Override previous keys if already exists + * + * @return string[] List of resources created + */ + public function generateKeys(bool $override = false): array; + + /** + * Encrypt a secret. + */ + public function encrypt(string $secret): string; + + /** + * Decrypt a secret. + */ + public function decrypt(string $encryptedSecret): string; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php new file mode 100644 index 000000000000..f621304f4462 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Encoder; + +use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ +class SodiumEncoder implements EncoderInterface +{ + private $encryptionKey; + private $encryptionKeyPath; + + public function __construct(string $encryptionKeyPath) + { + if (!\function_exists('\sodium_crypto_stream_xor')) { + throw new \RuntimeException('The "sodium" PHP extension is not loaded.'); + } + + $this->encryptionKeyPath = $encryptionKeyPath; + } + + /** + * {@inheritdoc} + */ + public function generateKeys(bool $override = false): array + { + if (!$override && file_exists($this->encryptionKeyPath)) { + throw new \LogicException(sprintf('A key already exists in "%s".', $this->encryptionKeyPath)); + } + + $this->encryptionKey = null; + + $encryptionKey = sodium_crypto_stream_keygen(); + (new Filesystem())->dumpFile($this->encryptionKeyPath, $encryptionKey); + sodium_memzero($encryptionKey); + + return [$this->encryptionKeyPath]; + } + + /** + * {@inheritdoc} + */ + public function encrypt(string $secret): string + { + $nonce = random_bytes(\SODIUM_CRYPTO_STREAM_NONCEBYTES); + + $key = $this->getKey(); + $encryptedSecret = sodium_crypto_stream_xor($secret, $nonce, $key); + sodium_memzero($secret); + sodium_memzero($key); + + return $this->encode($nonce, $encryptedSecret); + } + + public function decrypt(string $encryptedSecret): string + { + [$nonce, $encryptedSecret] = $this->decode($encryptedSecret); + + $key = $this->getKey(); + $secret = sodium_crypto_stream_xor($encryptedSecret, $nonce, $key); + sodium_memzero($key); + + return $secret; + } + + private function getKey(): string + { + if (isset($this->encryptionKey)) { + return $this->encryptionKey; + } + if (!is_file($this->encryptionKeyPath)) { + throw new EncryptionKeyNotFoundException($this->encryptionKeyPath); + } + + return $this->encryptionKey = file_get_contents($this->encryptionKeyPath); + } + + private function encode(string $nonce, string $encryptedSecret): string + { + return $nonce.$encryptedSecret; + } + + /** + * @return array [$nonce, $encryptedSecret] + */ + private function decode(string $message): array + { + if (\strlen($message) < \SODIUM_CRYPTO_STREAM_NONCEBYTES) { + throw new \UnexpectedValueException(sprintf('Invalid encrypted secret, message should be at least %s chars long.', \SODIUM_CRYPTO_STREAM_NONCEBYTES)); + } + + $nonce = substr($message, 0, \SODIUM_CRYPTO_STREAM_NONCEBYTES); + $encryptedSecret = substr($message, \SODIUM_CRYPTO_STREAM_NONCEBYTES); + + return [$nonce, $encryptedSecret]; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php deleted file mode 100644 index 72a40724db77..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php +++ /dev/null @@ -1,49 +0,0 @@ -ciphertext = $ciphertext; - $this->nonce = $nonce; - } - - public function __toString() - { - return $this->nonce.$this->ciphertext; - } - - public function getCiphertext(): string - { - return $this->ciphertext; - } - - public function getNonce(): string - { - return $this->nonce; - } - - public static function createFromString(string $message): self - { - if (\strlen($message) < SODIUM_CRYPTO_STREAM_NONCEBYTES) { - throw new \RuntimeException('Invalid ciphertext. Message is too short.'); - } - - $nonce = substr($message, 0, SODIUM_CRYPTO_STREAM_NONCEBYTES); - $ciphertext = substr($message, SODIUM_CRYPTO_STREAM_NONCEBYTES); - - return new self($ciphertext, $nonce); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php deleted file mode 100644 index 1eebc3920a4a..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php +++ /dev/null @@ -1,69 +0,0 @@ -secretsFolder = $secretsFolder; - $this->encryptionKey = $encryptionKey; - } - - public function getSecret(string $key): string - { - return $this->decryptFile($this->getFilePath($key)); - } - - public function putSecret(string $key, string $secret): void - { - $nonce = random_bytes(SODIUM_CRYPTO_STREAM_NONCEBYTES); - $ciphertext = sodium_crypto_stream_xor($secret, $nonce, $this->encryptionKey); - - sodium_memzero($secret); - - $message = new EncryptedMessage($ciphertext, $nonce); - - file_put_contents($this->getFilePath($key), (string) $message); - } - - public function deleteSecret(string $key): void - { - unlink($this->getFilePath($key)); - } - - public function listSecrets(): iterable - { - foreach (scandir($this->secretsFolder) as $fileName) { - if ('.' === $fileName || '..' === $fileName) { - continue; - } - - $key = basename($fileName, '.bin'); - yield $key => $this->getSecret($key); - } - } - - private function decryptFile(string $filePath): string - { - $encrypted = file_get_contents($filePath); - - $message = EncryptedMessage::createFromString($encrypted); - - return sodium_crypto_stream_xor($message->getCiphertext(), $message->getNonce(), $this->encryptionKey); - } - - private function getFilePath(string $key): string - { - return $this->secretsFolder.$key.'.bin'; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php index 17e5eb36646a..c91e8ba93027 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php @@ -11,8 +11,14 @@ namespace Symfony\Bundle\FrameworkBundle\Secret; +use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; +use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; +use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException; +/** + * @author Tobias Schultze + */ class SecretEnvVarProcessor implements EnvVarProcessorInterface { private $secretStorage; @@ -37,6 +43,10 @@ public static function getProvidedTypes() */ public function getEnv($prefix, $name, \Closure $getEnv) { - return $this->secretStorage->getSecret($name); + try { + return $this->secretStorage->getSecret($name); + } catch (SecretNotFoundException $e) { + throw new EnvNotFoundException($e->getMessage(), 0, $e); + } } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php deleted file mode 100644 index a57a11eb8ce7..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php +++ /dev/null @@ -1,14 +0,0 @@ - + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; + +use Symfony\Contracts\Cache\CacheInterface; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ +class CachedSecretStorage implements SecretStorageInterface +{ + private $decoratedStorage; + private $cache; + + public function __construct(SecretStorageInterface $decoratedStorage, CacheInterface $cache) + { + $this->decoratedStorage = $decoratedStorage; + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function getSecret(string $name): string + { + return $this->cache->get(md5(__CLASS__.$name), function () use ($name): string { + return $this->decoratedStorage->getSecret($name); + }); + } + + /** + * {@inheritdoc} + */ + public function listSecrets(bool $reveal = false): iterable + { + return $this->decoratedStorage->listSecrets($reveal); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php new file mode 100644 index 000000000000..4566bb1334e1 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; + +use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; + +/** + * @author Jérémy Derussé + * + * @final + */ +class ChainSecretStorage implements SecretStorageInterface +{ + private $secretStorages; + + /** + * @param SecretStorageInterface[] $secretStorages + */ + public function __construct(iterable $secretStorages = []) + { + $this->secretStorages = $secretStorages; + } + + /** + * {@inheritdoc} + */ + public function getSecret(string $name): string + { + foreach ($this->secretStorages as $secretStorage) { + try { + return $secretStorage->getSecret($name); + } catch (SecretNotFoundException $e) { + // ignore exception, to try the next storage + } + } + + throw new SecretNotFoundException($name); + } + + /** + * {@inheritdoc} + */ + public function listSecrets(bool $reveal = false): iterable + { + foreach ($this->secretStorages as $secretStorage) { + yield from $secretStorage->listSecrets($reveal); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php new file mode 100644 index 000000000000..a13d708a3047 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; + +use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; +use Symfony\Bundle\FrameworkBundle\Secret\Encoder\EncoderInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + */ +class FilesSecretStorage implements MutableSecretStorageInterface +{ + private const FILE_SUFFIX = '.bin'; + + private $secretsFolder; + private $encoder; + private $filesystem; + + public function __construct(string $secretsFolder, EncoderInterface $encoder) + { + $this->secretsFolder = rtrim($secretsFolder, '\\/'); + $this->encoder = $encoder; + $this->filesystem = new Filesystem(); + } + + /** + * {@inheritdoc} + */ + public function listSecrets(bool $reveal = false): iterable + { + if (!$this->filesystem->exists($this->secretsFolder)) { + return; + } + + foreach ((new Finder())->in($this->secretsFolder)->depth(0)->name('*'.self::FILE_SUFFIX)->files() as $file) { + $name = $file->getBasename(self::FILE_SUFFIX); + yield $name => $reveal ? $this->getSecret($name) : null; + } + } + + /** + * {@inheritdoc} + */ + public function getSecret(string $name): string + { + $filePath = $this->getFilePath($name); + + if (!is_file($filePath) || false === $content = file_get_contents($filePath)) { + throw new SecretNotFoundException($name); + } + + return $this->encoder->decrypt($content); + } + + /** + * {@inheritdoc} + */ + public function setSecret(string $name, string $secret): void + { + $this->filesystem->dumpFile($this->getFilePath($name), $this->encoder->encrypt($secret)); + } + + /** + * {@inheritdoc} + */ + public function removeSecret(string $name): void + { + $filePath = $this->getFilePath($name); + + if (!is_file($filePath)) { + throw new SecretNotFoundException($name); + } + + $this->filesystem->remove($this->getFilePath($name)); + } + + private function getFilePath(string $name): string + { + if (!preg_match('/^[\w\-]++$/', $name)) { + throw new \InvalidArgumentException(sprintf('The secret name "%s" is not valid.', $name)); + } + + return $this->secretsFolder.\DIRECTORY_SEPARATOR.$name.self::FILE_SUFFIX; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php new file mode 100644 index 000000000000..6a31b4df5ae6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; + +/** + * MutableSecretStorageInterface defines an interface to add and update a secrets in a storage. + * + * @author Jérémy Derussé + */ +interface MutableSecretStorageInterface extends SecretStorageInterface +{ + /** + * Adds or replaces a secret in the store. + */ + public function setSecret(string $name, string $secret): void; + + /** + * Removes a secret from the store. + */ + public function removeSecret(string $name): void; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php new file mode 100644 index 000000000000..e00d4ef71f70 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; + +use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; + +/** + * SecretStorageInterface defines an interface to retrieve secrets. + * + * @author Tobias Schultze + */ +interface SecretStorageInterface +{ + /** + * Retrieves a decrypted secret from the storage. + * + * @throws SecretNotFoundException + */ + public function getSecret(string $name): string; + + /** + * Returns a list of all secrets indexed by their name. + * + * @param bool $reveal when true, returns the decrypted secret, null otherwise + * + * @return iterable a list of key => value pairs + */ + public function listSecrets(bool $reveal = false): iterable; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 05ffb61b70b2..195d58528605 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -437,6 +437,11 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class), ], 'error_controller' => 'error_controller', + 'secrets' => [ + 'enabled' => false, + 'encrypted_secrets_dir' => '%kernel.project_dir%/config/secrets/%kernel.environment%', + 'encryption_key' => '%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key', + ], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php new file mode 100644 index 000000000000..91da32128a30 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php @@ -0,0 +1,71 @@ +keyPath = tempnam(sys_get_temp_dir(), 'secret'); + unlink($this->keyPath); + } + + protected function tearDown() + { + @unlink($this->keyPath); + } + + public function testGenerateKey() + { + $encoder = new SodiumEncoder($this->keyPath); + $resources = $encoder->generateKeys(); + + $this->assertCount(1, $resources); + $this->assertEquals($this->keyPath, $resources[0]); + $this->assertEquals(32, \strlen(file_get_contents($this->keyPath))); + } + + public function testGenerateCheckOtherKey() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessageRegExp('/^A key already exists in/'); + + $encoder = new SodiumEncoder($this->keyPath); + + $encoder->generateKeys(); + $encoder->generateKeys(); + } + + public function testGenerateOverride() + { + $encoder = new SodiumEncoder($this->keyPath); + + $encoder->generateKeys(); + $firstKey = file_get_contents($this->keyPath); + $encoder->generateKeys(true); + $secondKey = file_get_contents($this->keyPath); + + $this->assertNotEquals($firstKey, $secondKey); + } + + public function testEncryptAndDecrypt() + { + $encoder = new SodiumEncoder($this->keyPath); + $encoder->generateKeys(); + + $plain = 'plain'; + + $encrypted = $encoder->encrypt($plain); + $this->assertNotEquals($plain, $encrypted); + $decrypted = $encoder->decrypt($encrypted); + $this->assertEquals($plain, $decrypted); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php new file mode 100644 index 000000000000..2457c7148ef3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php @@ -0,0 +1,51 @@ +getMockBuilder(SecretStorageInterface::class)->getMock(); + $storage1 + ->expects($this->once()) + ->method('getSecret') + ->with('foo') + ->willThrowException(new SecretNotFoundException('foo')); + $storage2 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); + $storage2 + ->expects($this->once()) + ->method('getSecret') + ->with('foo') + ->willReturn('bar'); + + $chainStorage = new ChainSecretStorage([$storage1, $storage2]); + + $this->assertEquals('bar', $chainStorage->getSecret('foo')); + } + + public function testListSecrets() + { + $storage1 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); + $storage1 + ->expects($this->once()) + ->method('listSecrets') + ->with(true) + ->willReturn(['foo' => 'bar']); + $storage2 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); + $storage2 + ->expects($this->once()) + ->method('listSecrets') + ->with(true) + ->willReturn(['baz' => 'qux']); + + $chainStorage = new ChainSecretStorage([$storage1, $storage2]); + + $this->assertEquals(['foo' => 'bar', 'baz' => 'qux'], iterator_to_array($chainStorage->listSecrets(true))); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php new file mode 100644 index 000000000000..58f549a3dae6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php @@ -0,0 +1,74 @@ +workDir = tempnam(sys_get_temp_dir(), 'secret'); + $fs = new Filesystem(); + $fs->remove($this->workDir); + $fs->mkdir($this->workDir); + $this->encoder = new SodiumEncoder($this->workDir.'/key'); + $this->encoder->generateKeys(); + } + + protected function tearDown() + { + (new Filesystem())->remove($this->workDir); + unset($this->encoder); + } + + public function testPutAndGetSecrets() + { + $storage = new FilesSecretStorage($this->workDir, $this->encoder); + + $secrets = iterator_to_array($storage->listSecrets()); + $this->assertEmpty($secrets); + + $storage->setSecret('foo', 'bar'); + + $this->assertEquals('bar', $storage->getSecret('foo')); + } + + public function testGetThrowsNotFound() + { + $this->expectException(\Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException::class); + + $storage = new FilesSecretStorage($this->workDir, $this->encoder); + + $storage->getSecret('not-found'); + } + + public function testListSecrets() + { + $storage = new FilesSecretStorage($this->workDir, $this->encoder); + + $secrets = iterator_to_array($storage->listSecrets()); + $this->assertEmpty($secrets); + + $storage->setSecret('foo', 'bar'); + + $secrets = iterator_to_array($storage->listSecrets()); + $this->assertCount(1, $secrets); + $this->assertEquals(['foo'], array_keys($secrets)); + $this->assertEquals([null], array_values($secrets)); + + $secrets = iterator_to_array($storage->listSecrets(true)); + $this->assertCount(1, $secrets); + $this->assertEquals(['foo'], array_keys($secrets)); + $this->assertEquals(['bar'], array_values($secrets)); + } +} From c4653e1f65c1df24bd5e73bc5118a5506834f3a4 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Oct 2019 12:34:37 +0200 Subject: [PATCH 3/6] Restrict secrets management to sodium+filesystem --- .travis.yml | 1 + composer.json | 1 + .../Bundle/FrameworkBundle/CHANGELOG.md | 2 +- .../Command/SecretsAddCommand.php | 70 ------- .../Command/SecretsDecryptToLocalCommand.php | 90 +++++++++ .../SecretsEncryptFromLocalCommand.php | 90 +++++++++ .../Command/SecretsGenerateKeyCommand.php | 97 ---------- .../Command/SecretsGenerateKeysCommand.php | 125 +++++++++++++ .../Command/SecretsListCommand.php | 86 +++++---- .../Command/SecretsRemoveCommand.php | 49 +++-- .../Command/SecretsSetCommand.php | 134 +++++++++++++ .../DependencyInjection/Configuration.php | 7 +- .../FrameworkExtension.php | 19 +- .../EncryptionKeyNotFoundException.php | 28 --- .../Exception/SecretNotFoundException.php | 28 --- .../Resources/config/console.xml | 35 +++- .../Resources/config/secrets.xml | 39 ++-- .../Secret/Encoder/EncoderInterface.php | 39 ---- .../Secret/Encoder/SodiumEncoder.php | 110 ----------- .../Secret/Storage/CachedSecretStorage.php | 48 ----- .../Secret/Storage/ChainSecretStorage.php | 58 ------ .../Secret/Storage/FilesSecretStorage.php | 97 ---------- .../Storage/MutableSecretStorageInterface.php | 30 --- .../Secret/Storage/SecretStorageInterface.php | 38 ---- .../FrameworkBundle/Secrets/AbstractVault.php | 49 +++++ .../FrameworkBundle/Secrets/DotenvVault.php | 110 +++++++++++ .../SecretEnvVarProcessor.php | 27 ++- .../FrameworkBundle/Secrets/SodiumVault.php | 176 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 7 +- .../Secret/Encoder/SodiumEncoderTest.php | 71 ------- .../Secret/Storage/ChainSecretStorageTest.php | 51 ----- .../Secret/Storage/FilesSecretStorageTest.php | 74 -------- .../Tests/Secrets/DotenvVaultTest.php | 57 ++++++ .../Tests/Secrets/SodiumVaultTest.php | 64 +++++++ .../Bundle/FrameworkBundle/composer.json | 4 +- 35 files changed, 1073 insertions(+), 938 deletions(-) delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/CachedSecretStorage.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php rename src/Symfony/Bundle/FrameworkBundle/{Secret => Secrets}/SecretEnvVarProcessor.php (53%) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php diff --git a/.travis.yml b/.travis.yml index a79fd98b9770..86ae88730c43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -207,6 +207,7 @@ install: if [[ ! $deps ]]; then php .github/build-packages.php HEAD^ src/Symfony/Bridge/PhpUnit src/Symfony/Contracts + composer remove --dev --no-update paragonie/sodium_compat else export SYMFONY_DEPRECATIONS_HELPER=weak && cp composer.json composer.json.orig && diff --git a/composer.json b/composer.json index ef3906cb328a..d6c555b2010e 100644 --- a/composer.json +++ b/composer.json @@ -113,6 +113,7 @@ "monolog/monolog": "^1.25.1", "nyholm/psr7": "^1.0", "ocramius/proxy-manager": "^2.1", + "paragonie/sodium_compat": "^1.8", "php-http/httplug": "^1.0|^2.0", "predis/predis": "~1.1", "psr/http-client": "^1.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 1d093df8ffaf..eb9f8fc48610 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -17,7 +17,7 @@ CHANGELOG * Added new `error_controller` configuration to handle system exceptions * Added sort option for `translation:update` command. * [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore. - * Added secrets management. + * Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly. 4.3.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php deleted file mode 100644 index 4d2d9d27aed6..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Command; - -use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -/** - * @author Tobias Schultze - * @author Jérémy Derussé - */ -final class SecretsAddCommand extends Command -{ - protected static $defaultName = 'secrets:add'; - - private $secretsStorage; - - public function __construct(MutableSecretStorageInterface $secretsStorage) - { - $this->secretsStorage = $secretsStorage; - - parent::__construct(); - } - - protected function configure() - { - $this - ->setDefinition([ - new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'), - ]) - ->setDescription('Adds a secret in the storage.') - ->setHelp(<<<'EOF' -The %command.name% command stores a secret. - - %command.full_name% -EOF - ) - ; - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $io = new SymfonyStyle($input, $output); - - $name = $input->getArgument('name'); - $secret = $io->askHidden('Value of the secret'); - - try { - $this->secretsStorage->setSecret($name, $secret); - } catch (EncryptionKeyNotFoundException $e) { - throw new \LogicException(sprintf('No encryption keys found. You should call the "%s" command.', SecretsGenerateKeyCommand::getDefaultName())); - } - - $io->success('Secret was successfully stored.'); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php new file mode 100644 index 000000000000..c9370768f0a0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsDecryptToLocalCommand extends Command +{ + protected static $defaultName = 'secrets:decrypt-to-local'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Decrypts all secrets and stores them in the local vault.') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the local vault') + ->setHelp(<<<'EOF' +The %command.name% command list decrypts all secrets and stores them in the local vault.. + + %command.full_name% + +When the option --force is provided, secrets that already exist in the local vault are overriden. + + %command.full_name% --force +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if (null === $this->localVault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + $secrets = $this->vault->list(true); + + if (!$input->getOption('force')) { + foreach ($this->localVault->list() as $k => $v) { + unset($secrets[$k]); + } + } + + foreach ($secrets as $k => $v) { + if (null === $v) { + $io->error($this->vault->getLastMessage()); + + return 1; + } + + $this->localVault->seal($k, $v); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php new file mode 100644 index 000000000000..3161a2198206 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsEncryptFromLocalCommand extends Command +{ + protected static $defaultName = 'secrets:encrypt-from-local'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Encrypts all local secrets to the vault.') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the vault') + ->setHelp(<<<'EOF' +The %command.name% command list encrypts all local secrets and stores them in the vault.. + + %command.full_name% + +When the option --force is provided, secrets that already exist in the vault are overriden. + + %command.full_name% --force +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if (null === $this->localVault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + $secrets = $this->localVault->list(true); + + if (!$input->getOption('force')) { + foreach ($this->vault->list() as $k => $v) { + unset($secrets[$k]); + } + } + + foreach ($secrets as $k => $v) { + if (null === $v) { + $io->error($this->localVault->getLastMessage()); + + return 1; + } + + $this->vault->seal($k, $v); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php deleted file mode 100644 index d443c404bf77..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php +++ /dev/null @@ -1,97 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Command; - -use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; -use Symfony\Bundle\FrameworkBundle\Secret\Encoder\EncoderInterface; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; -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; - -/** - * @author Tobias Schultze - * @author Jérémy Derussé - */ -final class SecretsGenerateKeyCommand extends Command -{ - protected static $defaultName = 'secrets:generate-key'; - private $secretsStorage; - private $encoder; - - public function __construct(EncoderInterface $encoder, MutableSecretStorageInterface $secretsStorage) - { - $this->secretsStorage = $secretsStorage; - $this->encoder = $encoder; - parent::__construct(); - } - - protected function configure() - { - $this - ->setDefinition([ - new InputOption('rekey', 'r', InputOption::VALUE_NONE, 'Re-encrypt previous secret with the new key.'), - ]) - ->setDescription('Generates a new encryption key.') - ->setHelp(<<<'EOF' -The %command.name% command generates a new encryption key. - - %command.full_name% - -If a previous encryption key already exists, the command must be called with -the --rekey option in order to override that key and re-encrypt -previous secrets. - - %command.full_name% --rekey -EOF - ) - ; - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $rekey = $input->getOption('rekey'); - - $previousSecrets = []; - try { - foreach ($this->secretsStorage->listSecrets(true) as $name => $decryptedSecret) { - $previousSecrets[$name] = $decryptedSecret; - } - } catch (EncryptionKeyNotFoundException $e) { - if (!$rekey) { - throw $e; - } - } - - $keys = $this->encoder->generateKeys($rekey); - foreach ($previousSecrets as $name => $decryptedSecret) { - $this->secretsStorage->setSecret($name, $decryptedSecret); - } - - $io = new SymfonyStyle($input, $output); - switch (\count($keys)) { - case 0: - $io->success('Keys have been generated.'); - break; - case 1: - $io->success(sprintf('A key has been generated in "%s".', $keys[0])); - $io->caution('DO NOT COMMIT that file!'); - break; - default: - $io->success(sprintf("Keys have been generated in :\n -%s", implode("\n -", $keys))); - $io->caution('DO NOT COMMIT those files!'); - break; - } - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php new file mode 100644 index 000000000000..f56fd0fe6c5e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsGenerateKeysCommand extends Command +{ + protected static $defaultName = 'secrets:generate-keys'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Generates new encryption keys.') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') + ->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.') + ->setHelp(<<<'EOF' +The %command.name% command generates a new encryption key. + + %command.full_name% + +If encryption keys already exist, the command must be called with +the --rotate option in order to override those keys and re-encrypt +existing secrets. + + %command.full_name% --rotate +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->success('The local vault is disabled.'); + + return 1; + } + + if (!$input->getOption('rotate')) { + if ($vault->generateKeys()) { + $io->success($vault->getLastMessage()); + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + + return 0; + } + + $io->warning($vault->getLastMessage()); + + return 1; + } + + $secrets = []; + foreach ($vault->list(true) as $name => $value) { + if (null === $value) { + $io->error($vault->getLastMessage()); + + return 1; + } + + $secrets[$name] = $value; + } + + if (!$vault->generateKeys(true)) { + $io->warning($vault->getLastMessage()); + + return 1; + } + + $io->success($vault->getLastMessage()); + + if ($secrets) { + foreach ($secrets as $name => $value) { + $vault->seal($name, $value); + } + + $io->comment('Existing secrets have been rotated to the new keys.'); + } + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php index 09864eb741d1..4ae190435db8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -11,27 +11,33 @@ namespace Symfony\Bundle\FrameworkBundle\Command; -use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Dumper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** * @author Tobias Schultze * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal */ final class SecretsListCommand extends Command { - protected static $defaultName = 'debug:secrets'; + protected static $defaultName = 'secrets:list'; - private $secretStorage; + private $vault; + private $localVault; - public function __construct(SecretStorageInterface $secretStorage) + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) { - $this->secretStorage = $secretStorage; + $this->vault = $vault; + $this->localVault = $localVault; parent::__construct(); } @@ -39,54 +45,64 @@ public function __construct(SecretStorageInterface $secretStorage) protected function configure() { $this - ->setDefinition([ - new InputOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names'), - ]) ->setDescription('Lists all secrets.') + ->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names') ->setHelp(<<<'EOF' The %command.name% command list all stored secrets. - %command.full_name% + %command.full_name% -When the the option --reveal is provided, the decrypted secrets are also displayed. +When the option --reveal is provided, the decrypted secrets are also displayed. - %command.full_name% --reveal + %command.full_name% --reveal EOF ) ; - - $this - ->setDescription('Lists all secrets.') - ->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $reveal = $input->getOption('reveal'); - $io = new SymfonyStyle($input, $output); + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + $io->comment('Use "%env(secret:)%" to reference a secret in a config file.'); + + if (!$reveal = $input->getOption('reveal')) { + $io->comment(sprintf('To reveal the secrets run php %s %s --reveal', $_SERVER['PHP_SELF'], $this->getName())); + } + + $secrets = $this->vault->list($reveal); + $localSecrets = null !== $this->localVault ? $this->localVault->list($reveal) : null; - try { - $secrets = $this->secretStorage->listSecrets($reveal); - } catch (EncryptionKeyNotFoundException $e) { - throw new \LogicException(sprintf('Unable to decrypt secrets, the encryption key "%s" is missing.', $e->getKeyLocation())); + $rows = []; + + $dump = new Dumper($output); + $dump = static function (?string $v) use ($dump) { + return null === $v ? '******' : $dump($v); + }; + + foreach ($secrets as $name => $value) { + $rows[$name] = [$name, $dump($value)]; } - if ($reveal) { - $rows = []; - foreach ($secrets as $name => $value) { - $rows[] = [$name, $value]; - } - $io->table(['name', 'secret'], $rows); + if (null !== $message = $this->vault->getLastMessage()) { + $io->comment($message); + } - return; + foreach ($localSecrets ?? [] as $name => $value) { + $rows[$name] = [$name, $rows[$name][1] ?? '', $dump($value)]; } - $rows = []; - foreach ($secrets as $name => $_) { - $rows[] = [$name]; + uksort($rows, 'strnatcmp'); + + if (null !== $this->localVault && null !== $message = $this->localVault->getLastMessage()) { + $io->comment($message); } - $io->comment(sprintf('To reveal the values of the secrets use php %s %s --reveal', $_SERVER['PHP_SELF'], $this->getName())); - $io->table(['name'], $rows); + (new SymfonyStyle($input, $output)) + ->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows); + + $io->comment("Local values override secret values.\nUse secrets:set --local to defined them."); + + return 0; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php index 173166b05be9..b0ce9a89fedf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php @@ -11,25 +11,32 @@ namespace Symfony\Bundle\FrameworkBundle\Command; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface; +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal */ final class SecretsRemoveCommand extends Command { protected static $defaultName = 'secrets:remove'; - private $secretsStorage; + private $vault; + private $localVault; - public function __construct(MutableSecretStorageInterface $secretsStorage) + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) { - $this->secretsStorage = $secretsStorage; + $this->vault = $vault; + $this->localVault = $localVault; parent::__construct(); } @@ -37,25 +44,39 @@ public function __construct(MutableSecretStorageInterface $secretsStorage) protected function configure() { $this - ->setDefinition([ - new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'), - ]) - ->setDescription('Removes a secret from the storage.') + ->setDescription('Removes a secret from the vault.') + ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') ->setHelp(<<<'EOF' -The %command.name% command remove a secret. +The %command.name% command removes a secret from the vault. - %command.full_name% + %command.full_name% EOF ) ; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->success('The local vault is disabled.'); + + return 1; + } + + if ($vault->remove($name = $input->getArgument('name'))) { + $io->success($vault->getLastMessage() ?? 'Secret was removed from the vault.'); + } else { + $io->comment($vault->getLastMessage() ?? 'Secret was not found in the vault.'); + } - $this->secretsStorage->removeSecret($input->getArgument('name')); + if ($this->vault === $vault && null !== $this->localVault->reveal($name)) { + $io->comment('Note that this secret is overridden in the local vault.'); + } - $io->success('Secret was successfully removed.'); + return 0; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php new file mode 100644 index 000000000000..850cf08ee32b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsSetCommand extends Command +{ + protected static $defaultName = 'secrets:set'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Sets a secret in the vault.') + ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') + ->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') + ->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generates a random value.', false) + ->setHelp(<<<'EOF' +The %command.name% command stores a secret in the vault. + + %command.full_name% + +To reference secrets in services.yaml or any other config +files, use "%env(secret:)%". + +By default, the secret value should be entered interactively. +Alternatively, provide a file where to read the secret from: + + php %command.full_name% filename + +Use "-" as a file name to read from STDIN: + + cat filename | php %command.full_name% - + +Use --local to override secrets for local needs. +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $io = new SymfonyStyle($input, $errOutput); + $name = $input->getArgument('name'); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + if (0 < $random = $input->getOption('random') ?? 16) { + $value = strtr(substr(base64_encode(random_bytes($random)), 0, $random), '+/', '-_'); + } elseif (!$file = $input->getArgument('file')) { + $value = $io->askHidden('Please type the secret value'); + } elseif ('-' === $file) { + $value = file_get_contents('php://stdin'); + } elseif (is_file($file) && is_readable($file)) { + $value = file_get_contents($file); + } elseif (!is_file($file)) { + throw new \InvalidArgumentException(sprintf('File not found: "%s".', $file)); + } elseif (!is_readable($file)) { + throw new \InvalidArgumentException(sprintf('File is not readable: "%s".', $file)); + } + + if (null === $value) { + $io->warning('No value provided, aborting.'); + + return 1; + } + + if ($vault->generateKeys()) { + $io->success($vault->getLastMessage()); + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + } + + $vault->seal($name, $value); + + $io->success($vault->getLastMessage() ?? 'Secret was successfully stored in the vault.'); + + if (0 < $random) { + $errOutput->write(' // The generated random value is: '); + $output->write($value); + $errOutput->writeln(''); + $io->newLine(); + } + + if ($this->vault === $vault && null !== $this->localVault->reveal($name)) { + $io->comment('Note that this secret is overridden in the local vault.'); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 7730bef5156c..3520d9962d93 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -125,10 +125,11 @@ private function addSecretsSection(ArrayNodeDefinition $rootNode) $rootNode ->children() ->arrayNode('secrets') - ->canBeEnabled() + ->canBeDisabled() ->children() - ->scalarNode('encrypted_secrets_dir')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end() - ->scalarNode('encryption_key')->defaultValue('%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key')->cannotBeEmpty()->end() + ->scalarNode('vault_directory')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end() + ->scalarNode('local_dotenv_file')->defaultValue('%kernel.project_dir%/.env.local')->end() + ->scalarNode('decryption_env_var')->defaultValue('base64:default::SYMFONY_DECRYPTION_SECRET')->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 8b5aea7b260d..2c8d0808593a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,7 +25,6 @@ use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Bundle\FrameworkBundle\Routing\RedirectableUrlMatcher; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; use Symfony\Bundle\FullStack; use Symfony\Component\Asset\PackageInterface; use Symfony\Component\BrowserKit\AbstractBrowser; @@ -1446,23 +1445,29 @@ private function registerPropertyAccessConfiguration(array $config, ContainerBui private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { if (!$this->isConfigEnabled($container, $config)) { - $container->removeDefinition('console.command.secrets_add'); + $container->removeDefinition('console.command.secrets_set'); $container->removeDefinition('console.command.secrets_list'); $container->removeDefinition('console.command.secrets_remove'); $container->removeDefinition('console.command.secrets_generate_key'); + $container->removeDefinition('console.command.secrets_decrypt_to_local'); + $container->removeDefinition('console.command.secrets_encrypt_from_local'); return; } $loader->load('secrets.xml'); - $container->setAlias(SecretStorageInterface::class, new Alias('secrets.storage.cache', false)); + $container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']); - $container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']); - $container->getDefinition('secrets.encoder.sodium')->replaceArgument(0, $config['encryption_key']); + if (!$config['local_dotenv_file']) { + $container->removeDefinition('secrets.local_vault'); + } - $container->registerForAutoconfiguration(SecretStorageInterface::class) - ->addTag('secret_storage'); + if ($config['decryption_env_var']) { + $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); + } else { + $container->removeDefinition('secrets.decryption_key'); + } } private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php deleted file mode 100644 index be2592c86c5a..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Exception/EncryptionKeyNotFoundException.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Exception; - -class EncryptionKeyNotFoundException extends \RuntimeException -{ - private $keyLocation; - - public function __construct(string $keyLocation) - { - $this->keyLocation = $keyLocation; - parent::__construct(sprintf('Encryption key not found in "%s".', $keyLocation)); - } - - public function getKeyLocation(): string - { - return $this->keyLocation; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php deleted file mode 100644 index ac63dc4775b6..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Exception/SecretNotFoundException.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Exception; - -class SecretNotFoundException extends \RuntimeException -{ - private $name; - - public function __construct(string $name) - { - $this->name = $name; - parent::__construct(sprintf('The secret "%s" does not exist.', $name)); - } - - public function getName(): string - { - return $this->name; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index e5cb8e4c4b63..9a586e0a58fe 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -201,25 +201,40 @@ - - - + + + + - + + - - - - + + + + - - + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml index 4989dd99734e..d0ba0bcb3450 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -5,27 +5,36 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd"> - - + + %kernel.project_dir%/config/secrets/%kernel.environment% + - - - - + + + + + + + + + getEnv + + + + base64:default::SYMFONY_DECRYPTION_SECRET - - + + %kernel.project_dir%/.env.local - - - - - - - + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php deleted file mode 100644 index c1fe5e6bc3cd..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/EncoderInterface.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Encoder; - -/** - * EncoderInterface defines an interface to encrypt and decrypt secrets. - * - * @author Jérémy Derussé - */ -interface EncoderInterface -{ - /** - * Generate the keys and material necessary for its operation. - * - * @param bool $override Override previous keys if already exists - * - * @return string[] List of resources created - */ - public function generateKeys(bool $override = false): array; - - /** - * Encrypt a secret. - */ - public function encrypt(string $secret): string; - - /** - * Decrypt a secret. - */ - public function decrypt(string $encryptedSecret): string; -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php deleted file mode 100644 index f621304f4462..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Encoder/SodiumEncoder.php +++ /dev/null @@ -1,110 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Encoder; - -use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException; -use Symfony\Component\Filesystem\Filesystem; - -/** - * @author Tobias Schultze - * @author Jérémy Derussé - */ -class SodiumEncoder implements EncoderInterface -{ - private $encryptionKey; - private $encryptionKeyPath; - - public function __construct(string $encryptionKeyPath) - { - if (!\function_exists('\sodium_crypto_stream_xor')) { - throw new \RuntimeException('The "sodium" PHP extension is not loaded.'); - } - - $this->encryptionKeyPath = $encryptionKeyPath; - } - - /** - * {@inheritdoc} - */ - public function generateKeys(bool $override = false): array - { - if (!$override && file_exists($this->encryptionKeyPath)) { - throw new \LogicException(sprintf('A key already exists in "%s".', $this->encryptionKeyPath)); - } - - $this->encryptionKey = null; - - $encryptionKey = sodium_crypto_stream_keygen(); - (new Filesystem())->dumpFile($this->encryptionKeyPath, $encryptionKey); - sodium_memzero($encryptionKey); - - return [$this->encryptionKeyPath]; - } - - /** - * {@inheritdoc} - */ - public function encrypt(string $secret): string - { - $nonce = random_bytes(\SODIUM_CRYPTO_STREAM_NONCEBYTES); - - $key = $this->getKey(); - $encryptedSecret = sodium_crypto_stream_xor($secret, $nonce, $key); - sodium_memzero($secret); - sodium_memzero($key); - - return $this->encode($nonce, $encryptedSecret); - } - - public function decrypt(string $encryptedSecret): string - { - [$nonce, $encryptedSecret] = $this->decode($encryptedSecret); - - $key = $this->getKey(); - $secret = sodium_crypto_stream_xor($encryptedSecret, $nonce, $key); - sodium_memzero($key); - - return $secret; - } - - private function getKey(): string - { - if (isset($this->encryptionKey)) { - return $this->encryptionKey; - } - if (!is_file($this->encryptionKeyPath)) { - throw new EncryptionKeyNotFoundException($this->encryptionKeyPath); - } - - return $this->encryptionKey = file_get_contents($this->encryptionKeyPath); - } - - private function encode(string $nonce, string $encryptedSecret): string - { - return $nonce.$encryptedSecret; - } - - /** - * @return array [$nonce, $encryptedSecret] - */ - private function decode(string $message): array - { - if (\strlen($message) < \SODIUM_CRYPTO_STREAM_NONCEBYTES) { - throw new \UnexpectedValueException(sprintf('Invalid encrypted secret, message should be at least %s chars long.', \SODIUM_CRYPTO_STREAM_NONCEBYTES)); - } - - $nonce = substr($message, 0, \SODIUM_CRYPTO_STREAM_NONCEBYTES); - $encryptedSecret = substr($message, \SODIUM_CRYPTO_STREAM_NONCEBYTES); - - return [$nonce, $encryptedSecret]; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/CachedSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/CachedSecretStorage.php deleted file mode 100644 index 54c3a28b3340..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/CachedSecretStorage.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; - -use Symfony\Contracts\Cache\CacheInterface; - -/** - * @author Tobias Schultze - * @author Jérémy Derussé - */ -class CachedSecretStorage implements SecretStorageInterface -{ - private $decoratedStorage; - private $cache; - - public function __construct(SecretStorageInterface $decoratedStorage, CacheInterface $cache) - { - $this->decoratedStorage = $decoratedStorage; - $this->cache = $cache; - } - - /** - * {@inheritdoc} - */ - public function getSecret(string $name): string - { - return $this->cache->get(md5(__CLASS__.$name), function () use ($name): string { - return $this->decoratedStorage->getSecret($name); - }); - } - - /** - * {@inheritdoc} - */ - public function listSecrets(bool $reveal = false): iterable - { - return $this->decoratedStorage->listSecrets($reveal); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php deleted file mode 100644 index 4566bb1334e1..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/ChainSecretStorage.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; - -use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; - -/** - * @author Jérémy Derussé - * - * @final - */ -class ChainSecretStorage implements SecretStorageInterface -{ - private $secretStorages; - - /** - * @param SecretStorageInterface[] $secretStorages - */ - public function __construct(iterable $secretStorages = []) - { - $this->secretStorages = $secretStorages; - } - - /** - * {@inheritdoc} - */ - public function getSecret(string $name): string - { - foreach ($this->secretStorages as $secretStorage) { - try { - return $secretStorage->getSecret($name); - } catch (SecretNotFoundException $e) { - // ignore exception, to try the next storage - } - } - - throw new SecretNotFoundException($name); - } - - /** - * {@inheritdoc} - */ - public function listSecrets(bool $reveal = false): iterable - { - foreach ($this->secretStorages as $secretStorage) { - yield from $secretStorage->listSecrets($reveal); - } - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php deleted file mode 100644 index a13d708a3047..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/FilesSecretStorage.php +++ /dev/null @@ -1,97 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; - -use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; -use Symfony\Bundle\FrameworkBundle\Secret\Encoder\EncoderInterface; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; - -/** - * @author Tobias Schultze - * @author Jérémy Derussé - */ -class FilesSecretStorage implements MutableSecretStorageInterface -{ - private const FILE_SUFFIX = '.bin'; - - private $secretsFolder; - private $encoder; - private $filesystem; - - public function __construct(string $secretsFolder, EncoderInterface $encoder) - { - $this->secretsFolder = rtrim($secretsFolder, '\\/'); - $this->encoder = $encoder; - $this->filesystem = new Filesystem(); - } - - /** - * {@inheritdoc} - */ - public function listSecrets(bool $reveal = false): iterable - { - if (!$this->filesystem->exists($this->secretsFolder)) { - return; - } - - foreach ((new Finder())->in($this->secretsFolder)->depth(0)->name('*'.self::FILE_SUFFIX)->files() as $file) { - $name = $file->getBasename(self::FILE_SUFFIX); - yield $name => $reveal ? $this->getSecret($name) : null; - } - } - - /** - * {@inheritdoc} - */ - public function getSecret(string $name): string - { - $filePath = $this->getFilePath($name); - - if (!is_file($filePath) || false === $content = file_get_contents($filePath)) { - throw new SecretNotFoundException($name); - } - - return $this->encoder->decrypt($content); - } - - /** - * {@inheritdoc} - */ - public function setSecret(string $name, string $secret): void - { - $this->filesystem->dumpFile($this->getFilePath($name), $this->encoder->encrypt($secret)); - } - - /** - * {@inheritdoc} - */ - public function removeSecret(string $name): void - { - $filePath = $this->getFilePath($name); - - if (!is_file($filePath)) { - throw new SecretNotFoundException($name); - } - - $this->filesystem->remove($this->getFilePath($name)); - } - - private function getFilePath(string $name): string - { - if (!preg_match('/^[\w\-]++$/', $name)) { - throw new \InvalidArgumentException(sprintf('The secret name "%s" is not valid.', $name)); - } - - return $this->secretsFolder.\DIRECTORY_SEPARATOR.$name.self::FILE_SUFFIX; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php deleted file mode 100644 index 6a31b4df5ae6..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/MutableSecretStorageInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; - -/** - * MutableSecretStorageInterface defines an interface to add and update a secrets in a storage. - * - * @author Jérémy Derussé - */ -interface MutableSecretStorageInterface extends SecretStorageInterface -{ - /** - * Adds or replaces a secret in the store. - */ - public function setSecret(string $name, string $secret): void; - - /** - * Removes a secret from the store. - */ - public function removeSecret(string $name): void; -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php deleted file mode 100644 index e00d4ef71f70..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/Storage/SecretStorageInterface.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\Secret\Storage; - -use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; - -/** - * SecretStorageInterface defines an interface to retrieve secrets. - * - * @author Tobias Schultze - */ -interface SecretStorageInterface -{ - /** - * Retrieves a decrypted secret from the storage. - * - * @throws SecretNotFoundException - */ - public function getSecret(string $name): string; - - /** - * Returns a list of all secrets indexed by their name. - * - * @param bool $reveal when true, returns the decrypted secret, null otherwise - * - * @return iterable a list of key => value pairs - */ - public function listSecrets(bool $reveal = false): iterable; -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php new file mode 100644 index 000000000000..eeecbbb68b68 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Nicolas Grekas + * + * @internal + */ +abstract class AbstractVault +{ + protected $lastMessage; + + public function getLastMessage(): ?string + { + return $this->lastMessage; + } + + abstract public function generateKeys(bool $override = false): bool; + + abstract public function seal(string $name, string $value): void; + + abstract public function reveal(string $name): ?string; + + abstract public function remove(string $name): bool; + + abstract public function list(bool $reveal = false): array; + + protected function validateName(string $name): void + { + if (!preg_match('/^\w++$/D', $name)) { + throw new \LogicException(sprintf('Invalid secret name "%s": only "word" characters are allowed.', $name)); + } + } + + protected function getPrettyPath(string $path) + { + return str_replace(getcwd().\DIRECTORY_SEPARATOR, '', $path); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php new file mode 100644 index 000000000000..16df2a6045f7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class DotenvVault extends AbstractVault +{ + private $dotenvFile; + + public function __construct(string $dotenvFile) + { + $this->dotenvFile = strtr($dotenvFile, '/', \DIRECTORY_SEPARATOR); + } + + public function generateKeys(bool $override = false): bool + { + $this->lastMessage = 'The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.'; + + return false; + } + + public function seal(string $name, string $value): void + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + $v = str_replace("'", "'\\''", $value); + + $content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : ''; + $content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)/m", "$k='$v'", $content, -1, $count); + + if (!$count) { + $content .= "$k='$v'\n"; + } + + file_put_contents($this->dotenvFile, $content); + + $this->lastMessage = sprintf('Secret "%s" %s in "%s".', $name, $count ? 'added' : 'updated', $this->getPrettyPath($this->dotenvFile)); + } + + public function reveal(string $name): ?string + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + $v = \is_string($_SERVER[$k] ?? null) ? $_SERVER[$k] : ($_ENV[$k] ?? null); + + if (null === $v) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return null; + } + + return $v; + } + + public function remove(string $name): bool + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + + $content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : ''; + $content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)\n?/m", '', $content, -1, $count); + + if ($count) { + file_put_contents($this->dotenvFile, $content); + $this->lastMessage = sprintf('Secret "%s" removed from file "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return true; + } + + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return false; + } + + public function list(bool $reveal = false): array + { + $this->lastMessage = null; + $secrets = []; + + foreach ($_ENV as $k => $v) { + if (preg_match('/^(\w+)_SECRET$/D', $k, $m)) { + $secrets[$m[1]] = $reveal ? $v : null; + } + } + + foreach ($_SERVER as $k => $v) { + if (\is_string($v) && preg_match('/^(\w+)_SECRET$/D', $k, $m)) { + $secrets[$m[1]] = $reveal ? $v : null; + } + } + + return $secrets; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php similarity index 53% rename from src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php rename to src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php index c91e8ba93027..5a4771fa365f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php @@ -9,23 +9,26 @@ * file that was distributed with this source code. */ -namespace Symfony\Bundle\FrameworkBundle\Secret; +namespace Symfony\Bundle\FrameworkBundle\Secrets; -use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException; -use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface; use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException; /** * @author Tobias Schultze + * @author Nicolas Grekas + * + * @internal */ class SecretEnvVarProcessor implements EnvVarProcessorInterface { - private $secretStorage; + private $vault; + private $localVault; - public function __construct(SecretStorageInterface $secretStorage) + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) { - $this->secretStorage = $secretStorage; + $this->vault = $vault; + $this->localVault = $localVault; } /** @@ -43,10 +46,14 @@ public static function getProvidedTypes() */ public function getEnv($prefix, $name, \Closure $getEnv) { - try { - return $this->secretStorage->getSecret($name); - } catch (SecretNotFoundException $e) { - throw new EnvNotFoundException($e->getMessage(), 0, $e); + if (null !== $this->localVault && null !== $secret = $this->localVault->reveal($name)) { + return $secret; + } + + if (null !== $secret = $this->vault->reveal($name)) { + return $secret; } + + throw new EnvNotFoundException($this->vault->getLastMessage() ?? sprintf('Secret "%s" not found or decryption key is missing.', $name)); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php new file mode 100644 index 000000000000..8aae669d8f53 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +class SodiumVault extends AbstractVault +{ + private $encryptionKey; + private $decryptionKey; + private $pathPrefix; + + /** + * @param string|object|null $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault + * or null to store generated keys in the provided $secretsDir + */ + public function __construct(string $secretsDir, $decryptionKey = null) + { + if (!\function_exists('sodium_crypto_box_seal')) { + throw new \LogicException('The "sodium" PHP extension is required to deal with secrets. Alternatively, try running "composer require paragonie/sodium_compat" if you cannot enable the extension."'); + } + + if (null !== $decryptionKey && !\is_string($decryptionKey) && !(\is_object($decryptionKey) && method_exists($decryptionKey, '__toString'))) { + throw new \TypeError(sprintf('Decryption key should be a string or an object that implements the __toString() method, %s given.', \gettype($decryptionKey))); + } + + if (!is_dir($secretsDir) && !@mkdir($secretsDir, 0777, true) && !is_dir($secretsDir)) { + throw new \RuntimeException(sprintf('Unable to create the secrets directory (%s)', $secretsDir)); + } + + $this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.'; + $this->decryptionKey = $decryptionKey; + } + + public function generateKeys(bool $override = false): bool + { + $this->lastMessage = null; + + if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) { + throw new \LogicException('Cannot generate keys when a decryption key has been provided while instantiating the vault.'); + } + + try { + $this->loadKeys(); + } catch (\LogicException $e) { + // ignore failures to load keys + } + + if ('' !== $this->decryptionKey && !file_exists($this->pathPrefix.'sodium.encrypt.public')) { + $this->export('sodium.encrypt.public', $this->encryptionKey); + } + + if (!$override && null !== $this->encryptionKey) { + $this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix)); + + return false; + } + + $this->decryptionKey = sodium_crypto_box_keypair(); + $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey); + + $this->export('sodium.encrypt.public', $this->encryptionKey); + $this->export('sodium.decrypt.private', $this->decryptionKey); + + $this->lastMessage = sprintf('Sodium keys have been generated at "%s*.{public,private}".', $this->getPrettyPath($this->pathPrefix)); + + return true; + } + + public function seal(string $name, string $value): void + { + $this->lastMessage = null; + $this->validateName($name); + $this->loadKeys(); + $this->export($name.'.'.substr_replace(md5($name), '.sodium', -26), sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey))); + $this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + } + + public function reveal(string $name): ?string + { + $this->lastMessage = null; + $this->validateName($name); + + if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return null; + } + + $this->loadKeys(); + + if ('' === $this->decryptionKey) { + $this->lastMessage = sprintf('Secrets cannot be revealed as no decryption key was found in "%s".', $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return null; + } + + return sodium_crypto_box_seal_open(include $file, $this->decryptionKey); + } + + public function remove(string $name): bool + { + $this->lastMessage = null; + $this->validateName($name); + + if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return false; + } + + $this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return @unlink($file) || !file_exists($file); + } + + public function list(bool $reveal = false): array + { + $this->lastMessage = null; + $secrets = []; + $regexp = sprintf('{^%s(\w++)\.[0-9a-f]{6}\.sodium$}D', preg_quote(basename($this->pathPrefix))); + + foreach (scandir(\dirname($this->pathPrefix)) as $name) { + if (preg_match($regexp, $name, $m)) { + $secrets[$m[1]] = $reveal ? $this->reveal($m[1]) : null; + } + } + + return $secrets; + } + + private function loadKeys(): void + { + if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) { + return; + } + + if (file_exists($this->pathPrefix.'sodium.decrypt.private')) { + $this->decryptionKey = (string) include $this->pathPrefix.'sodium.decrypt.private'; + } + + if (file_exists($this->pathPrefix.'sodium.encrypt.public')) { + $this->encryptionKey = (string) include $this->pathPrefix.'sodium.encrypt.public'; + } elseif ('' !== $this->decryptionKey) { + $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey); + } else { + throw new \LogicException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix))); + } + } + + private function export(string $file, string $data): void + { + $name = basename($this->pathPrefix.$file); + $data = str_replace('%', '\x', rawurlencode($data)); + $data = sprintf("pathPrefix.$file, $data, LOCK_EX)) { + $e = error_get_last(); + throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? E_USER_WARNING); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 195d58528605..f79897a6badd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -438,9 +438,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor ], 'error_controller' => 'error_controller', 'secrets' => [ - 'enabled' => false, - 'encrypted_secrets_dir' => '%kernel.project_dir%/config/secrets/%kernel.environment%', - 'encryption_key' => '%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key', + 'enabled' => true, + 'vault_directory' => '%kernel.project_dir%/config/secrets/%kernel.environment%', + 'local_dotenv_file' => '%kernel.project_dir%/.env.local', + 'decryption_env_var' => 'base64:default::SYMFONY_DECRYPTION_SECRET', ], ]; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php deleted file mode 100644 index 91da32128a30..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Encoder/SodiumEncoderTest.php +++ /dev/null @@ -1,71 +0,0 @@ -keyPath = tempnam(sys_get_temp_dir(), 'secret'); - unlink($this->keyPath); - } - - protected function tearDown() - { - @unlink($this->keyPath); - } - - public function testGenerateKey() - { - $encoder = new SodiumEncoder($this->keyPath); - $resources = $encoder->generateKeys(); - - $this->assertCount(1, $resources); - $this->assertEquals($this->keyPath, $resources[0]); - $this->assertEquals(32, \strlen(file_get_contents($this->keyPath))); - } - - public function testGenerateCheckOtherKey() - { - $this->expectException(\LogicException::class); - $this->expectExceptionMessageRegExp('/^A key already exists in/'); - - $encoder = new SodiumEncoder($this->keyPath); - - $encoder->generateKeys(); - $encoder->generateKeys(); - } - - public function testGenerateOverride() - { - $encoder = new SodiumEncoder($this->keyPath); - - $encoder->generateKeys(); - $firstKey = file_get_contents($this->keyPath); - $encoder->generateKeys(true); - $secondKey = file_get_contents($this->keyPath); - - $this->assertNotEquals($firstKey, $secondKey); - } - - public function testEncryptAndDecrypt() - { - $encoder = new SodiumEncoder($this->keyPath); - $encoder->generateKeys(); - - $plain = 'plain'; - - $encrypted = $encoder->encrypt($plain); - $this->assertNotEquals($plain, $encrypted); - $decrypted = $encoder->decrypt($encrypted); - $this->assertEquals($plain, $decrypted); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php deleted file mode 100644 index 2457c7148ef3..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/ChainSecretStorageTest.php +++ /dev/null @@ -1,51 +0,0 @@ -getMockBuilder(SecretStorageInterface::class)->getMock(); - $storage1 - ->expects($this->once()) - ->method('getSecret') - ->with('foo') - ->willThrowException(new SecretNotFoundException('foo')); - $storage2 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); - $storage2 - ->expects($this->once()) - ->method('getSecret') - ->with('foo') - ->willReturn('bar'); - - $chainStorage = new ChainSecretStorage([$storage1, $storage2]); - - $this->assertEquals('bar', $chainStorage->getSecret('foo')); - } - - public function testListSecrets() - { - $storage1 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); - $storage1 - ->expects($this->once()) - ->method('listSecrets') - ->with(true) - ->willReturn(['foo' => 'bar']); - $storage2 = $this->getMockBuilder(SecretStorageInterface::class)->getMock(); - $storage2 - ->expects($this->once()) - ->method('listSecrets') - ->with(true) - ->willReturn(['baz' => 'qux']); - - $chainStorage = new ChainSecretStorage([$storage1, $storage2]); - - $this->assertEquals(['foo' => 'bar', 'baz' => 'qux'], iterator_to_array($chainStorage->listSecrets(true))); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php deleted file mode 100644 index 58f549a3dae6..000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Secret/Storage/FilesSecretStorageTest.php +++ /dev/null @@ -1,74 +0,0 @@ -workDir = tempnam(sys_get_temp_dir(), 'secret'); - $fs = new Filesystem(); - $fs->remove($this->workDir); - $fs->mkdir($this->workDir); - $this->encoder = new SodiumEncoder($this->workDir.'/key'); - $this->encoder->generateKeys(); - } - - protected function tearDown() - { - (new Filesystem())->remove($this->workDir); - unset($this->encoder); - } - - public function testPutAndGetSecrets() - { - $storage = new FilesSecretStorage($this->workDir, $this->encoder); - - $secrets = iterator_to_array($storage->listSecrets()); - $this->assertEmpty($secrets); - - $storage->setSecret('foo', 'bar'); - - $this->assertEquals('bar', $storage->getSecret('foo')); - } - - public function testGetThrowsNotFound() - { - $this->expectException(\Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException::class); - - $storage = new FilesSecretStorage($this->workDir, $this->encoder); - - $storage->getSecret('not-found'); - } - - public function testListSecrets() - { - $storage = new FilesSecretStorage($this->workDir, $this->encoder); - - $secrets = iterator_to_array($storage->listSecrets()); - $this->assertEmpty($secrets); - - $storage->setSecret('foo', 'bar'); - - $secrets = iterator_to_array($storage->listSecrets()); - $this->assertCount(1, $secrets); - $this->assertEquals(['foo'], array_keys($secrets)); - $this->assertEquals([null], array_values($secrets)); - - $secrets = iterator_to_array($storage->listSecrets(true)); - $this->assertCount(1, $secrets); - $this->assertEquals(['foo'], array_keys($secrets)); - $this->assertEquals(['bar'], array_values($secrets)); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php new file mode 100644 index 000000000000..ba234349d76b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php @@ -0,0 +1,57 @@ +envFile = sys_get_temp_dir().'/sf_secrets.env.test'; + @unlink($this->envFile); + } + + protected function tearDown(): void + { + @unlink($this->envFile); + } + + public function testGenerateKeys() + { + $vault = new DotenvVault($this->envFile); + + $this->assertFalse($vault->generateKeys()); + $this->assertSame('The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.', $vault->getLastMessage()); + } + + public function testEncryptAndDecrypt() + { + $vault = new DotenvVault($this->envFile); + + $plain = "plain\ntext"; + + $vault->seal('foo', $plain); + + unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']); + (new Dotenv(false))->load($this->envFile); + + $decrypted = $vault->reveal('foo'); + $this->assertSame($plain, $decrypted); + + $this->assertSame(['foo' => null], $vault->list()); + $this->assertSame(['foo' => $plain], $vault->list(true)); + + $this->assertTrue($vault->remove('foo')); + $this->assertFalse($vault->remove('foo')); + + unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']); + (new Dotenv(false))->load($this->envFile); + + $this->assertSame([], $vault->list()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php new file mode 100644 index 000000000000..2e25df902462 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php @@ -0,0 +1,64 @@ +secretsDir = sys_get_temp_dir().'/sf_secrets/test/'; + (new Filesystem())->remove($this->secretsDir); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->secretsDir); + } + + public function testGenerateKeys() + { + $vault = new SodiumVault($this->secretsDir); + + $this->assertTrue($vault->generateKeys()); + $this->assertFileExists($this->secretsDir.'/test.sodium.encrypt.public'); + $this->assertFileExists($this->secretsDir.'/test.sodium.decrypt.private'); + + $encKey = file_get_contents($this->secretsDir.'/test.sodium.encrypt.public'); + $decKey = file_get_contents($this->secretsDir.'/test.sodium.decrypt.private'); + + $this->assertFalse($vault->generateKeys()); + $this->assertStringEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey); + $this->assertStringEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey); + + $this->assertTrue($vault->generateKeys(true)); + $this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey); + $this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey); + } + + public function testEncryptAndDecrypt() + { + $vault = new SodiumVault($this->secretsDir); + $vault->generateKeys(); + + $plain = "plain\ntext"; + + $vault->seal('foo', $plain); + + $decrypted = $vault->reveal('foo'); + $this->assertSame($plain, $decrypted); + + $this->assertSame(['foo' => null], $vault->list()); + $this->assertSame(['foo' => $plain], $vault->list(true)); + + $this->assertTrue($vault->remove('foo')); + $this->assertFalse($vault->remove('foo')); + + $this->assertSame([], $vault->list()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index d75499f3fa12..e67a3c94bff9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -32,11 +32,13 @@ "require-dev": { "doctrine/annotations": "~1.7", "doctrine/cache": "~1.0", + "paragonie/sodium_compat": "^1.8", "symfony/asset": "^3.4|^4.0|^5.0", "symfony/browser-kit": "^4.3|^5.0", "symfony/console": "^4.3.4|^5.0", "symfony/css-selector": "^3.4|^4.0|^5.0", "symfony/dom-crawler": "^4.3|^5.0", + "symfony/dotenv": "^4.3.6|^5.0", "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^4.3.4|^5.0", "symfony/expression-language": "^3.4|^4.0|^5.0", @@ -69,7 +71,7 @@ "symfony/asset": "<3.4", "symfony/browser-kit": "<4.3", "symfony/console": "<4.3", - "symfony/dotenv": "<4.2", + "symfony/dotenv": "<4.3.6", "symfony/dom-crawler": "<4.3", "symfony/http-client": "<4.4", "symfony/form": "<4.3", From 7c2b974e26230a507a66c07e3648b73a12d3a395 Mon Sep 17 00:00:00 2001 From: Menno Holtkamp Date: Mon, 21 Oct 2019 17:20:49 +0200 Subject: [PATCH 4/6] Fix small typo in Exception message --- .../Component/Workflow/Validator/StateMachineValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php index 7dbe69494051..6330cabec99c 100644 --- a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php +++ b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php @@ -37,7 +37,7 @@ public function validate(Definition $definition, $name) // Enforcing uniqueness of the names of transitions starting at each node $from = reset($froms); if (isset($transitionFromNames[$from][$transition->getName()])) { - throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" where found on StateMachine "%s". ', $transition->getName(), $from, $name)); + throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); } $transitionFromNames[$from][$transition->getName()] = true; From bbb8ed11ff20bf9dad9918cf176762b8e45a2762 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Mon, 21 Oct 2019 10:45:40 +0200 Subject: [PATCH 5/6] [HttpClient] Add a canceled state to the ResponseInterface --- src/Symfony/Component/HttpClient/CHANGELOG.md | 1 + .../Component/HttpClient/NativeHttpClient.php | 1 + .../HttpClient/Response/MockResponse.php | 1 + .../HttpClient/Response/ResponseTrait.php | 2 ++ .../HttpClient/Tests/MockHttpClientTest.php | 1 + .../HttpClient/ResponseInterface.php | 19 ++++++++++--------- .../HttpClient/Test/HttpClientTestCase.php | 11 +++++++++++ 7 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index bddd592b3b8a..a116ff72ec34 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 4.4.0 ----- + * added `canceled` to `ResponseInterface::getInfo()` * added `HttpClient::createForBaseUri()` * added `HttplugClient` with support for sync and async requests * added `max_duration` option diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index fcafffaf590e..b86b7c84eb3a 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -96,6 +96,7 @@ public function request(string $method, string $url, array $options = []): Respo 'response_headers' => [], 'url' => $url, 'error' => null, + 'canceled' => false, 'http_method' => $method, 'http_code' => 0, 'redirect_count' => 0, diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index 4c5c322c1b2c..59db21cc3fd0 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -84,6 +84,7 @@ public function getInfo(string $type = null) */ public function cancel(): void { + $this->info['canceled'] = true; $this->info['error'] = 'Response has been canceled.'; $this->body = null; } diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index d27acfa5502c..4cd7710f788a 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -52,6 +52,7 @@ trait ResponseTrait 'response_headers' => [], 'http_code' => 0, 'error' => null, + 'canceled' => false, ]; /** @var resource */ @@ -178,6 +179,7 @@ public function toArray(bool $throw = true): array */ public function cancel(): void { + $this->info['canceled'] = true; $this->info['error'] = 'Response has been canceled.'; $this->close(); } diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index efbaebc4e710..f0721aa8a27c 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -105,6 +105,7 @@ protected function getHttpClient(string $testCase): HttpClientInterface case 'testOnProgressError': case 'testReentrantBufferCallback': case 'testThrowingBufferCallback': + case 'testInfoOnCanceledResponse': $responses[] = new MockResponse($body, ['response_headers' => $headers]); break; diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php index 8628c8daf163..b9917ac5f354 100644 --- a/src/Symfony/Contracts/HttpClient/ResponseInterface.php +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -88,15 +88,16 @@ public function cancel(): void; * another, as the request/response progresses. * * The following info MUST be returned: - * - response_headers - an array modelled after the special $http_response_header variable - * - redirect_count - the number of redirects followed while executing the request - * - redirect_url - the resolved location of redirect responses, null otherwise - * - start_time - the time when the request was sent or 0.0 when it's pending - * - http_method - the HTTP verb of the last request - * - http_code - the last response code or 0 when it is not known yet - * - error - the error message when the transfer was aborted, null otherwise - * - user_data - the value of the "user_data" request option, null if not set - * - url - the last effective URL of the request + * - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise + * - error (string|null) - the error message when the transfer was aborted, null otherwise + * - http_code (int) - the last response code or 0 when it is not known yet + * - http_method (string) - the HTTP verb of the last request + * - redirect_count (int) - the number of redirects followed while executing the request + * - redirect_url (string|null) - the resolved location of redirect responses, null otherwise + * - response_headers (array) - an array modelled after the special $http_response_header variable + * - start_time (float) - the time when the request was sent or 0.0 when it's pending + * - url (string) - the last effective URL of the request + * - user_data (mixed|null) - the value of the "user_data" request option, null if not set * * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index 0c76a41f64fb..11ba1ae6d2a3 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -515,6 +515,17 @@ public function testCancel() $response->getHeaders(); } + public function testInfoOnCanceledResponse() + { + $client = $this->getHttpClient(__FUNCTION__); + + $response = $client->request('GET', 'http://localhost:8057/timeout-header'); + + $this->assertFalse($response->getInfo('canceled')); + $response->cancel(); + $this->assertTrue($response->getInfo('canceled')); + } + public function testCancelInStream() { $client = $this->getHttpClient(__FUNCTION__); From f592322251b782985361aa565a35c23cac350072 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 22 Oct 2019 08:15:10 +0200 Subject: [PATCH 6/6] [Console] Revert wrong change --- src/Symfony/Component/Console/Helper/QuestionHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index bba719b534f2..80906db4eda2 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -115,7 +115,7 @@ private function doAsk(OutputInterface $output, Question $question) { $this->writePrompt($output, $question); - $inputStream = $this->inputStream ?: fopen('php://stdin', 'r'); + $inputStream = $this->inputStream ?: STDIN; $autocomplete = $question->getAutocompleterCallback(); if (null === $autocomplete || !Terminal::hasSttyAvailable()) {