Add secrets management
This commit is contained in:
parent
8c8f62390a
commit
02b5d740e5
@ -17,6 +17,7 @@ CHANGELOG
|
|||||||
* Added new `error_controller` configuration to handle system exceptions
|
* Added new `error_controller` configuration to handle system exceptions
|
||||||
* Added sort option for `translation:update` command.
|
* Added sort option for `translation:update` command.
|
||||||
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
|
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
|
||||||
|
* Added secrets management.
|
||||||
|
|
||||||
4.3.0
|
4.3.0
|
||||||
-----
|
-----
|
||||||
|
@ -1,27 +1,37 @@
|
|||||||
<?php
|
<?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;
|
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\Command\Command;
|
||||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
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
|
final class SecretsAddCommand extends Command
|
||||||
{
|
{
|
||||||
protected static $defaultName = 'secrets:add';
|
protected static $defaultName = 'secrets:add';
|
||||||
|
|
||||||
/**
|
private $secretsStorage;
|
||||||
* @var SecretStorageInterface
|
|
||||||
*/
|
|
||||||
private $secretStorage;
|
|
||||||
|
|
||||||
public function __construct(SecretStorageInterface $secretStorage)
|
public function __construct(MutableSecretStorageInterface $secretsStorage)
|
||||||
{
|
{
|
||||||
$this->secretStorage = $secretStorage;
|
$this->secretsStorage = $secretsStorage;
|
||||||
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@ -29,40 +39,32 @@ final class SecretsAddCommand extends Command
|
|||||||
protected function configure()
|
protected function configure()
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->setDescription('Adds a secret with the key.')
|
->setDefinition([
|
||||||
->addArgument(
|
new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'),
|
||||||
'key',
|
])
|
||||||
InputArgument::REQUIRED
|
->setDescription('Adds a secret in the storage.')
|
||||||
)
|
->setHelp(<<<'EOF'
|
||||||
->addArgument(
|
The <info>%command.name%</info> command stores a secret.
|
||||||
'secret',
|
|
||||||
InputArgument::REQUIRED
|
%command.full_name% <name>
|
||||||
|
EOF
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output)
|
protected function execute(InputInterface $input, OutputInterface $output)
|
||||||
{
|
{
|
||||||
$key = $input->getArgument('key');
|
$io = new SymfonyStyle($input, $output);
|
||||||
$secret = $input->getArgument('secret');
|
|
||||||
|
|
||||||
$this->secretStorage->putSecret($key, $secret);
|
$name = $input->getArgument('name');
|
||||||
}
|
$secret = $io->askHidden('Value of the secret');
|
||||||
|
|
||||||
protected function interact(InputInterface $input, OutputInterface $output)
|
try {
|
||||||
{
|
$this->secretsStorage->setSecret($name, $secret);
|
||||||
/** @var QuestionHelper $helper */
|
} catch (EncryptionKeyNotFoundException $e) {
|
||||||
$helper = $this->getHelper('question');
|
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'));
|
$io->success('Secret was successfully stored.');
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,97 @@
|
|||||||
<?php
|
<?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;
|
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\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
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
|
final class SecretsGenerateKeyCommand extends Command
|
||||||
{
|
{
|
||||||
protected static $defaultName = 'secrets:generate-key';
|
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()
|
protected function configure()
|
||||||
{
|
{
|
||||||
$this
|
$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)
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,32 @@
|
|||||||
<?php
|
<?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;
|
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\Command\Command;
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
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
|
final class SecretsListCommand extends Command
|
||||||
{
|
{
|
||||||
protected static $defaultName = 'secrets:list';
|
protected static $defaultName = 'debug:secrets';
|
||||||
|
|
||||||
/**
|
|
||||||
* @var SecretStorageInterface
|
|
||||||
*/
|
|
||||||
private $secretStorage;
|
private $secretStorage;
|
||||||
|
|
||||||
public function __construct(SecretStorageInterface $secretStorage)
|
public function __construct(SecretStorageInterface $secretStorage)
|
||||||
@ -27,19 +39,54 @@ final class SecretsListCommand extends Command
|
|||||||
protected function configure()
|
protected function configure()
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
|
->setDefinition([
|
||||||
|
new InputOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names'),
|
||||||
|
])
|
||||||
->setDescription('Lists all secrets.')
|
->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)
|
protected function execute(InputInterface $input, OutputInterface $output)
|
||||||
{
|
{
|
||||||
$table = new Table($output);
|
$reveal = $input->getOption('reveal');
|
||||||
$table->setHeaders(['key', 'plaintext secret']);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
foreach ($this->secretStorage->listSecrets() as $key => $secret) {
|
try {
|
||||||
$table->addRow([$key, $secret]);
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
@ -127,11 +127,8 @@ class Configuration implements ConfigurationInterface
|
|||||||
->arrayNode('secrets')
|
->arrayNode('secrets')
|
||||||
->canBeEnabled()
|
->canBeEnabled()
|
||||||
->children()
|
->children()
|
||||||
->scalarNode('encrypted_secrets_dir')->end()
|
->scalarNode('encrypted_secrets_dir')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end()
|
||||||
->scalarNode('encryption_key')->end()
|
->scalarNode('encryption_key')->defaultValue('%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key')->cannotBeEmpty()->end()
|
||||||
//->scalarNode('public_key')->end()
|
|
||||||
//->scalarNode('private_key')->end()
|
|
||||||
->scalarNode('decrypted_secrets_cache')->end()
|
|
||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
|
@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|||||||
use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader;
|
use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader;
|
||||||
use Symfony\Bundle\FrameworkBundle\Routing\RedirectableUrlMatcher;
|
use Symfony\Bundle\FrameworkBundle\Routing\RedirectableUrlMatcher;
|
||||||
use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface;
|
use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface;
|
||||||
use Symfony\Bundle\FullStack;
|
use Symfony\Bundle\FullStack;
|
||||||
use Symfony\Component\Asset\PackageInterface;
|
use Symfony\Component\Asset\PackageInterface;
|
||||||
use Symfony\Component\BrowserKit\AbstractBrowser;
|
use Symfony\Component\BrowserKit\AbstractBrowser;
|
||||||
@ -1446,14 +1447,22 @@ class FrameworkExtension extends Extension
|
|||||||
{
|
{
|
||||||
if (!$this->isConfigEnabled($container, $config)) {
|
if (!$this->isConfigEnabled($container, $config)) {
|
||||||
$container->removeDefinition('console.command.secrets_add');
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$loader->load('secrets.xml');
|
$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(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)
|
private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -202,17 +202,24 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service id="console.command.secrets_add" class="Symfony\Bundle\FrameworkBundle\Command\SecretsAddCommand">
|
<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" />
|
<tag name="console.command" command="secrets:add" />
|
||||||
</service>
|
</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">
|
<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" />
|
<tag name="console.command" command="secrets:generate-key" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service id="console.command.secrets_list" class="Symfony\Bundle\FrameworkBundle\Command\SecretsListCommand">
|
<service id="console.command.secrets_list" class="Symfony\Bundle\FrameworkBundle\Command\SecretsListCommand">
|
||||||
<argument type="service" id="secrets.storage.cache" />
|
<argument type="service" id="secrets.storage.cache" />
|
||||||
<tag name="console.command" command="secrets:list" />
|
<tag name="console.command" command="debug:secrets" />
|
||||||
</service>
|
</service>
|
||||||
</services>
|
</services>
|
||||||
</container>
|
</container>
|
||||||
|
@ -5,13 +5,22 @@
|
|||||||
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
|
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
|
||||||
|
|
||||||
<services>
|
<services>
|
||||||
<service id="secrets.storage.files" class="Symfony\Bundle\FrameworkBundle\Secret\FilesSecretStorage">
|
<service id="secrets.encoder.sodium" class="Symfony\Bundle\FrameworkBundle\Secret\Encoder\SodiumEncoder">
|
||||||
<argument />
|
|
||||||
<argument />
|
<argument />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service id="secrets.storage.cache" class="Symfony\Bundle\FrameworkBundle\Secret\CachedSecretStorage">
|
<service id="secrets.storage.files" class="Symfony\Bundle\FrameworkBundle\Secret\Storage\FilesSecretStorage">
|
||||||
<argument type="service" id="secrets.storage.files" />
|
<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" />
|
<argument type="service" id="cache.system" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
}
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,8 +11,14 @@
|
|||||||
|
|
||||||
namespace Symfony\Bundle\FrameworkBundle\Secret;
|
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\EnvVarProcessorInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Tobias Schultze <http://tobion.de>
|
||||||
|
*/
|
||||||
class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
||||||
{
|
{
|
||||||
private $secretStorage;
|
private $secretStorage;
|
||||||
@ -37,6 +43,10 @@ class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
|||||||
*/
|
*/
|
||||||
public function getEnv($prefix, $name, \Closure $getEnv)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -437,6 +437,11 @@ class ConfigurationTest extends TestCase
|
|||||||
'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class),
|
'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class),
|
||||||
],
|
],
|
||||||
'error_controller' => 'error_controller',
|
'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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user