Add secrets management

This commit is contained in:
Jérémy Derussé 2019-04-08 20:52:56 +02:00 committed by Nicolas Grekas
parent 8c8f62390a
commit 02b5d740e5
27 changed files with 952 additions and 273 deletions

View File

@ -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
-----

View File

@ -1,27 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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 <info>%command.name%</info> command stores a secret.
%command.full_name% <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.');
}
}

View File

@ -1,28 +1,97 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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 <info>%command.name%</info> command generates a new encryption key.
%command.full_name%
If a previous encryption key already exists, the command must be called with
the <info>--rekey</info> 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;
}
}
}

View File

@ -1,20 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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 <info>%command.name%</info> command list all stored secrets.
%command.full_name%
When the the option <info>--reveal</info> 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 <info>php %s %s --reveal</info>', $_SERVER['PHP_SELF'], $this->getName()));
$io->table(['name'], $rows);
}
}

View File

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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 <info>%command.name%</info> command remove a secret.
%command.full_name% <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.');
}
}

View File

@ -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()

View File

@ -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)

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -202,17 +202,24 @@
</service>
<service id="console.command.secrets_add" class="Symfony\Bundle\FrameworkBundle\Command\SecretsAddCommand">
<argument type="service" id="secrets.storage.cache" />
<argument type="service" id="secrets.storage.files" />
<tag name="console.command" command="secrets:add" />
</service>
<service id="console.command.secrets_remove" class="Symfony\Bundle\FrameworkBundle\Command\SecretsRemoveCommand">
<argument type="service" id="secrets.storage.files" />
<tag name="console.command" command="secrets:remove" />
</service>
<service id="console.command.secrets_generate_key" class="Symfony\Bundle\FrameworkBundle\Command\SecretsGenerateKeyCommand">
<argument type="service" id="secrets.encoder.sodium"/>
<argument type="service" id="secrets.storage.files"/>
<tag name="console.command" command="secrets:generate-key" />
</service>
<service id="console.command.secrets_list" class="Symfony\Bundle\FrameworkBundle\Command\SecretsListCommand">
<argument type="service" id="secrets.storage.cache" />
<tag name="console.command" command="secrets:list" />
<tag name="console.command" command="debug:secrets" />
</service>
</services>
</container>

View File

@ -5,13 +5,22 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="secrets.storage.files" class="Symfony\Bundle\FrameworkBundle\Secret\FilesSecretStorage">
<argument />
<service id="secrets.encoder.sodium" class="Symfony\Bundle\FrameworkBundle\Secret\Encoder\SodiumEncoder">
<argument />
</service>
<service id="secrets.storage.cache" class="Symfony\Bundle\FrameworkBundle\Secret\CachedSecretStorage">
<argument type="service" id="secrets.storage.files" />
<service id="secrets.storage.files" class="Symfony\Bundle\FrameworkBundle\Secret\Storage\FilesSecretStorage">
<argument />
<argument type="service" id="secrets.encoder.sodium"/>
<tag name="secret_storage" priority="1000"/>
</service>
<service id="secrets.storage.chain" class="Symfony\Bundle\FrameworkBundle\Secret\Storage\ChainSecretStorage">
<argument type="tagged_iterator" tag="secret_storage"/>
</service>
<service id="secrets.storage.cache" class="Symfony\Bundle\FrameworkBundle\Secret\Storage\CachedSecretStorage">
<argument type="service" id="secrets.storage.chain" />
<argument type="service" id="cache.system" />
</service>

View File

@ -1,78 +0,0 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
class CachedSecretStorage implements SecretStorageInterface
{
/**
* @var SecretStorageInterface
*/
private $decoratedStorage;
/**
* @var CacheItemPoolInterface
*/
private $cache;
public function __construct(SecretStorageInterface $decoratedStorage, CacheItemPoolInterface $cache)
{
$this->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;
}
}

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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;
}

View File

@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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];
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
class EncryptedMessage
{
/**
* @var string
*/
private $ciphertext;
/**
* @var string
*/
private $nonce;
public function __construct(string $ciphertext, string $nonce)
{
$this->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);
}
}

View File

@ -1,69 +0,0 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
class FilesSecretStorage implements SecretStorageInterface
{
/**
* @var string
*/
private $secretsFolder;
/**
* @var string
*/
private $encryptionKey;
public function __construct(string $secretsFolder, string $encryptionKey)
{
$this->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';
}
}

View File

@ -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 <http://tobion.de>
*/
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);
}
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
interface SecretStorageInterface
{
public function getSecret(string $key): string;
public function putSecret(string $key, string $secret): void;
public function deleteSecret(string $key): void;
public function listSecrets(): iterable;
}

View File

@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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);
}
}

View File

@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*
* @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);
}
}
}

View File

@ -0,0 +1,97 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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;
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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;
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <http://tobion.de>
*/
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;
}

View File

@ -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',
],
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Tests\Secret\Encoder;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Secret\Encoder\SodiumEncoder;
/**
* @requires extension sodium
*/
class SodiumEncoderTest extends TestCase
{
private $keyPath;
protected function setUp()
{
$this->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);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Tests\Secret\Storage;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException;
use Symfony\Bundle\FrameworkBundle\Secret\Storage\ChainSecretStorage;
use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface;
class ChainSecretStorageTest extends TestCase
{
public function testGetSecret()
{
$storage1 = $this->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)));
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Tests\Secret\Storage;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Secret\Encoder\SodiumEncoder;
use Symfony\Bundle\FrameworkBundle\Secret\Storage\FilesSecretStorage;
use Symfony\Component\Filesystem\Filesystem;
/**
* @requires extension sodium
*/
class FilesSecretStorageTest extends TestCase
{
private $workDir;
private $encoder;
protected function setUp()
{
$this->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));
}
}