diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php
new file mode 100644
index 0000000000..780d4c2373
--- /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 0000000000..fd1d1a8fae
--- /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 0000000000..c280acd6b1
--- /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 c34377fa0d..fd8901c804 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
@@ -115,10 +115,29 @@ class Configuration implements ConfigurationInterface
$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 1d83148ff7..97486af068 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -334,6 +334,7 @@ class FrameworkExtension extends Extension
$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 @@ class FrameworkExtension extends Extension
;
}
+ 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 f13aa759d3..a4249d1797 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 0000000000..8bdb2422ec
--- /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 0000000000..3fe9ff7184
--- /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 0000000000..72a40724db
--- /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 0000000000..1eebc3920a
--- /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 0000000000..17e5eb3664
--- /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 0000000000..a57a11eb8c
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php
@@ -0,0 +1,14 @@
+