diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 093f4bb1da..1d093df8ff 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 780d4c2373..4d2d9d27ae 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 @@ final class SecretsAddCommand extends Command 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'); + $io = new SymfonyStyle($input, $output); - $this->secretStorage->putSecret($key, $secret); - } + $name = $input->getArgument('name'); + $secret = $io->askHidden('Value of the secret'); - protected function interact(InputInterface $input, OutputInterface $output) - { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); + 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())); + } - $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); + $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 fd1d1a8fae..d443c404bf 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'); - $output->write($encryptionKey, false, OutputInterface::OUTPUT_RAW); + $previousSecrets = []; + try { + foreach ($this->secretsStorage->listSecrets(true) as $name => $decryptedSecret) { + $previousSecrets[$name] = $decryptedSecret; + } + } catch (EncryptionKeyNotFoundException $e) { + if (!$rekey) { + throw $e; + } + } - sodium_memzero($encryptionKey); + $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/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php index c280acd6b1..09864eb741 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 @@ final class SecretsListCommand extends Command 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); - foreach ($this->secretStorage->listSecrets() as $key => $secret) { - $table->addRow([$key, $secret]); + 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())); } - $table->render(); + if ($reveal) { + $rows = []; + foreach ($secrets as $name => $value) { + $rows[] = [$name, $value]; + } + $io->table(['name', 'secret'], $rows); + + return; + } + + $rows = []; + foreach ($secrets as $name => $_) { + $rows[] = [$name]; + } + + $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 0000000000..173166b05b --- /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 fd8901c804..7730bef515 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -127,11 +127,8 @@ class Configuration implements ConfigurationInterface ->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 97486af068..8b5aea7b26 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\Controller\AbstractController; 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 @@ class FrameworkExtension extends Extension { 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 0000000000..be2592c86c --- /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 0000000000..ac63dc4775 --- /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 a4249d1797..e5cb8e4c4b 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 8bdb2422ec..4989dd9973 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 3fe9ff7184..0000000000 --- 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 0000000000..c1fe5e6bc3 --- /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 0000000000..f621304f44 --- /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 72a40724db..0000000000 --- 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 1eebc3920a..0000000000 --- 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 17e5eb3664..c91e8ba930 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 @@ class SecretEnvVarProcessor implements EnvVarProcessorInterface */ 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 a57a11eb8c..0000000000 --- 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 0000000000..4566bb1334 --- /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 0000000000..a13d708a30 --- /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 0000000000..6a31b4df5a --- /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 0000000000..e00d4ef71f --- /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 05ffb61b70..195d585286 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -437,6 +437,11 @@ class ConfigurationTest extends TestCase '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 0000000000..91da32128a --- /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 0000000000..2457c7148e --- /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 0000000000..58f549a3da --- /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)); + } +}