Restrict secrets management to sodium+filesystem
This commit is contained in:
parent
02b5d740e5
commit
c4653e1f65
@ -207,6 +207,7 @@ install:
|
||||
|
||||
if [[ ! $deps ]]; then
|
||||
php .github/build-packages.php HEAD^ src/Symfony/Bridge/PhpUnit src/Symfony/Contracts
|
||||
composer remove --dev --no-update paragonie/sodium_compat
|
||||
else
|
||||
export SYMFONY_DEPRECATIONS_HELPER=weak &&
|
||||
cp composer.json composer.json.orig &&
|
||||
|
@ -113,6 +113,7 @@
|
||||
"monolog/monolog": "^1.25.1",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"ocramius/proxy-manager": "^2.1",
|
||||
"paragonie/sodium_compat": "^1.8",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"predis/predis": "~1.1",
|
||||
"psr/http-client": "^1.0",
|
||||
|
@ -17,7 +17,7 @@ CHANGELOG
|
||||
* Added new `error_controller` configuration to handle system exceptions
|
||||
* Added sort option for `translation:update` command.
|
||||
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
|
||||
* Added secrets management.
|
||||
* Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly.
|
||||
|
||||
4.3.0
|
||||
-----
|
||||
|
@ -1,70 +0,0 @@
|
||||
<?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\Storage\MutableSecretStorageInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
*/
|
||||
final class SecretsAddCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:add';
|
||||
|
||||
private $secretsStorage;
|
||||
|
||||
public function __construct(MutableSecretStorageInterface $secretsStorage)
|
||||
{
|
||||
$this->secretsStorage = $secretsStorage;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'),
|
||||
])
|
||||
->setDescription('Adds a secret in the storage.')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command stores a secret.
|
||||
|
||||
%command.full_name% <name>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$name = $input->getArgument('name');
|
||||
$secret = $io->askHidden('Value of the secret');
|
||||
|
||||
try {
|
||||
$this->secretsStorage->setSecret($name, $secret);
|
||||
} catch (EncryptionKeyNotFoundException $e) {
|
||||
throw new \LogicException(sprintf('No encryption keys found. You should call the "%s" command.', SecretsGenerateKeyCommand::getDefaultName()));
|
||||
}
|
||||
|
||||
$io->success('Secret was successfully stored.');
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
<?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\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsDecryptToLocalCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:decrypt-to-local';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Decrypts all secrets and stores them in the local vault.')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the local vault')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list decrypts all secrets and stores them in the local vault..
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the option <info>--force</info> is provided, secrets that already exist in the local vault are overriden.
|
||||
|
||||
<info>%command.full_name% --force</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
if (null === $this->localVault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = $this->vault->list(true);
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
foreach ($this->localVault->list() as $k => $v) {
|
||||
unset($secrets[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($secrets as $k => $v) {
|
||||
if (null === $v) {
|
||||
$io->error($this->vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->localVault->seal($k, $v);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
<?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\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsEncryptFromLocalCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:encrypt-from-local';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Encrypts all local secrets to the vault.')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the vault')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list encrypts all local secrets and stores them in the vault..
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the option <info>--force</info> is provided, secrets that already exist in the vault are overriden.
|
||||
|
||||
<info>%command.full_name% --force</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
if (null === $this->localVault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = $this->localVault->list(true);
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
foreach ($this->vault->list() as $k => $v) {
|
||||
unset($secrets[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($secrets as $k => $v) {
|
||||
if (null === $v) {
|
||||
$io->error($this->localVault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->vault->seal($k, $v);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
<?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
|
||||
->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)
|
||||
{
|
||||
$rekey = $input->getOption('rekey');
|
||||
|
||||
$previousSecrets = [];
|
||||
try {
|
||||
foreach ($this->secretsStorage->listSecrets(true) as $name => $decryptedSecret) {
|
||||
$previousSecrets[$name] = $decryptedSecret;
|
||||
}
|
||||
} catch (EncryptionKeyNotFoundException $e) {
|
||||
if (!$rekey) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = $this->encoder->generateKeys($rekey);
|
||||
foreach ($previousSecrets as $name => $decryptedSecret) {
|
||||
$this->secretsStorage->setSecret($name, $decryptedSecret);
|
||||
}
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
switch (\count($keys)) {
|
||||
case 0:
|
||||
$io->success('Keys have been generated.');
|
||||
break;
|
||||
case 1:
|
||||
$io->success(sprintf('A key has been generated in "%s".', $keys[0]));
|
||||
$io->caution('DO NOT COMMIT that file!');
|
||||
break;
|
||||
default:
|
||||
$io->success(sprintf("Keys have been generated in :\n -%s", implode("\n -", $keys)));
|
||||
$io->caution('DO NOT COMMIT those files!');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
<?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\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsGenerateKeysCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:generate-keys';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Generates new encryption keys.')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command generates a new encryption key.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
If encryption keys already exist, the command must be called with
|
||||
the <info>--rotate</info> option in order to override those keys and re-encrypt
|
||||
existing secrets.
|
||||
|
||||
<info>%command.full_name% --rotate</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
if (null === $vault) {
|
||||
$io->success('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$input->getOption('rotate')) {
|
||||
if ($vault->generateKeys()) {
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->warning($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = [];
|
||||
foreach ($vault->list(true) as $name => $value) {
|
||||
if (null === $value) {
|
||||
$io->error($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets[$name] = $value;
|
||||
}
|
||||
|
||||
if (!$vault->generateKeys(true)) {
|
||||
$io->warning($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($secrets) {
|
||||
foreach ($secrets as $name => $value) {
|
||||
$vault->seal($name, $value);
|
||||
}
|
||||
|
||||
$io->comment('Existing secrets have been rotated to the new keys.');
|
||||
}
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -11,27 +11,33 @@
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Exception\EncryptionKeyNotFoundException;
|
||||
use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Dumper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsListCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'debug:secrets';
|
||||
protected static $defaultName = 'secrets:list';
|
||||
|
||||
private $secretStorage;
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(SecretStorageInterface $secretStorage)
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->secretStorage = $secretStorage;
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
@ -39,54 +45,64 @@ 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.')
|
||||
->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list all stored secrets.
|
||||
|
||||
%command.full_name%
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the the option <info>--reveal</info> is provided, the decrypted secrets are also displayed.
|
||||
When the option <info>--reveal</info> is provided, the decrypted secrets are also displayed.
|
||||
|
||||
%command.full_name% --reveal
|
||||
<info>%command.full_name% --reveal</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
|
||||
$this
|
||||
->setDescription('Lists all secrets.')
|
||||
->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$reveal = $input->getOption('reveal');
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
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()));
|
||||
$io->comment('Use <info>"%env(secret:<name>)%"</info> to reference a secret in a config file.');
|
||||
|
||||
if (!$reveal = $input->getOption('reveal')) {
|
||||
$io->comment(sprintf('To reveal the secrets run <info>php %s %s --reveal</info>', $_SERVER['PHP_SELF'], $this->getName()));
|
||||
}
|
||||
|
||||
if ($reveal) {
|
||||
$rows = [];
|
||||
foreach ($secrets as $name => $value) {
|
||||
$rows[] = [$name, $value];
|
||||
}
|
||||
$io->table(['name', 'secret'], $rows);
|
||||
|
||||
return;
|
||||
}
|
||||
$secrets = $this->vault->list($reveal);
|
||||
$localSecrets = null !== $this->localVault ? $this->localVault->list($reveal) : null;
|
||||
|
||||
$rows = [];
|
||||
foreach ($secrets as $name => $_) {
|
||||
$rows[] = [$name];
|
||||
|
||||
$dump = new Dumper($output);
|
||||
$dump = static function (?string $v) use ($dump) {
|
||||
return null === $v ? '******' : $dump($v);
|
||||
};
|
||||
|
||||
foreach ($secrets as $name => $value) {
|
||||
$rows[$name] = [$name, $dump($value)];
|
||||
}
|
||||
|
||||
$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);
|
||||
if (null !== $message = $this->vault->getLastMessage()) {
|
||||
$io->comment($message);
|
||||
}
|
||||
|
||||
foreach ($localSecrets ?? [] as $name => $value) {
|
||||
$rows[$name] = [$name, $rows[$name][1] ?? '', $dump($value)];
|
||||
}
|
||||
|
||||
uksort($rows, 'strnatcmp');
|
||||
|
||||
if (null !== $this->localVault && null !== $message = $this->localVault->getLastMessage()) {
|
||||
$io->comment($message);
|
||||
}
|
||||
|
||||
(new SymfonyStyle($input, $output))
|
||||
->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows);
|
||||
|
||||
$io->comment("Local values override secret values.\nUse <info>secrets:set --local</info> to defined them.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
@ -11,25 +11,32 @@
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secret\Storage\MutableSecretStorageInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsRemoveCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:remove';
|
||||
|
||||
private $secretsStorage;
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(MutableSecretStorageInterface $secretsStorage)
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->secretsStorage = $secretsStorage;
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
@ -37,25 +44,39 @@ final class SecretsRemoveCommand extends Command
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
new InputArgument('name', InputArgument::REQUIRED, 'The name of the secret'),
|
||||
])
|
||||
->setDescription('Removes a secret from the storage.')
|
||||
->setDescription('Removes a secret from the vault.')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command remove a secret.
|
||||
The <info>%command.name%</info> command removes a secret from the vault.
|
||||
|
||||
%command.full_name% <name>
|
||||
<info>%command.full_name% <name></info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
$this->secretsStorage->removeSecret($input->getArgument('name'));
|
||||
if (null === $vault) {
|
||||
$io->success('The local vault is disabled.');
|
||||
|
||||
$io->success('Secret was successfully removed.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($vault->remove($name = $input->getArgument('name'))) {
|
||||
$io->success($vault->getLastMessage() ?? 'Secret was removed from the vault.');
|
||||
} else {
|
||||
$io->comment($vault->getLastMessage() ?? 'Secret was not found in the vault.');
|
||||
}
|
||||
|
||||
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
|
||||
$io->comment('Note that this secret is overridden in the local vault.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
134
src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php
Normal file
134
src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?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\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsSetCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:set';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Sets a secret in the vault.')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
|
||||
->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generates a random value.', false)
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command stores a secret in the vault.
|
||||
|
||||
<info>%command.full_name% <name></info>
|
||||
|
||||
To reference secrets in services.yaml or any other config
|
||||
files, use <info>"%env(secret:<name>)%"</info>.
|
||||
|
||||
By default, the secret value should be entered interactively.
|
||||
Alternatively, provide a file where to read the secret from:
|
||||
|
||||
<info>php %command.full_name% <name> filename</info>
|
||||
|
||||
Use "-" as a file name to read from STDIN:
|
||||
|
||||
<info>cat filename | php %command.full_name% <name> -</info>
|
||||
|
||||
Use <info>--local</info> to override secrets for local needs.
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
|
||||
$io = new SymfonyStyle($input, $errOutput);
|
||||
$name = $input->getArgument('name');
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
if (null === $vault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (0 < $random = $input->getOption('random') ?? 16) {
|
||||
$value = strtr(substr(base64_encode(random_bytes($random)), 0, $random), '+/', '-_');
|
||||
} elseif (!$file = $input->getArgument('file')) {
|
||||
$value = $io->askHidden('Please type the secret value');
|
||||
} elseif ('-' === $file) {
|
||||
$value = file_get_contents('php://stdin');
|
||||
} elseif (is_file($file) && is_readable($file)) {
|
||||
$value = file_get_contents($file);
|
||||
} elseif (!is_file($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('File not found: "%s".', $file));
|
||||
} elseif (!is_readable($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('File is not readable: "%s".', $file));
|
||||
}
|
||||
|
||||
if (null === $value) {
|
||||
$io->warning('No value provided, aborting.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($vault->generateKeys()) {
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
}
|
||||
|
||||
$vault->seal($name, $value);
|
||||
|
||||
$io->success($vault->getLastMessage() ?? 'Secret was successfully stored in the vault.');
|
||||
|
||||
if (0 < $random) {
|
||||
$errOutput->write(' // The generated random value is: <comment>');
|
||||
$output->write($value);
|
||||
$errOutput->writeln('</comment>');
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
|
||||
$io->comment('Note that this secret is overridden in the local vault.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -125,10 +125,11 @@ class Configuration implements ConfigurationInterface
|
||||
$rootNode
|
||||
->children()
|
||||
->arrayNode('secrets')
|
||||
->canBeEnabled()
|
||||
->canBeDisabled()
|
||||
->children()
|
||||
->scalarNode('encrypted_secrets_dir')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end()
|
||||
->scalarNode('encryption_key')->defaultValue('%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key')->cannotBeEmpty()->end()
|
||||
->scalarNode('vault_directory')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end()
|
||||
->scalarNode('local_dotenv_file')->defaultValue('%kernel.project_dir%/.env.local')->end()
|
||||
->scalarNode('decryption_env_var')->defaultValue('base64:default::SYMFONY_DECRYPTION_SECRET')->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
@ -25,7 +25,6 @@ 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,23 +1445,29 @@ class FrameworkExtension extends Extension
|
||||
private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
|
||||
{
|
||||
if (!$this->isConfigEnabled($container, $config)) {
|
||||
$container->removeDefinition('console.command.secrets_add');
|
||||
$container->removeDefinition('console.command.secrets_set');
|
||||
$container->removeDefinition('console.command.secrets_list');
|
||||
$container->removeDefinition('console.command.secrets_remove');
|
||||
$container->removeDefinition('console.command.secrets_generate_key');
|
||||
$container->removeDefinition('console.command.secrets_decrypt_to_local');
|
||||
$container->removeDefinition('console.command.secrets_encrypt_from_local');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$loader->load('secrets.xml');
|
||||
|
||||
$container->setAlias(SecretStorageInterface::class, new Alias('secrets.storage.cache', false));
|
||||
$container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']);
|
||||
|
||||
$container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']);
|
||||
$container->getDefinition('secrets.encoder.sodium')->replaceArgument(0, $config['encryption_key']);
|
||||
if (!$config['local_dotenv_file']) {
|
||||
$container->removeDefinition('secrets.local_vault');
|
||||
}
|
||||
|
||||
$container->registerForAutoconfiguration(SecretStorageInterface::class)
|
||||
->addTag('secret_storage');
|
||||
if ($config['decryption_env_var']) {
|
||||
$container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']);
|
||||
} else {
|
||||
$container->removeDefinition('secrets.decryption_key');
|
||||
}
|
||||
}
|
||||
|
||||
private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
|
||||
|
@ -1,28 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
@ -201,25 +201,40 @@
|
||||
<tag name="console.command" command="debug:error-renderer" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_add" class="Symfony\Bundle\FrameworkBundle\Command\SecretsAddCommand">
|
||||
<argument type="service" id="secrets.storage.files" />
|
||||
<tag name="console.command" command="secrets:add" />
|
||||
<service id="console.command.secrets_set" class="Symfony\Bundle\FrameworkBundle\Command\SecretsSetCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:set" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_remove" class="Symfony\Bundle\FrameworkBundle\Command\SecretsRemoveCommand">
|
||||
<argument type="service" id="secrets.storage.files" />
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<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 id="console.command.secrets_generate_key" class="Symfony\Bundle\FrameworkBundle\Command\SecretsGenerateKeysCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:generate-keys" />
|
||||
</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="debug:secrets" />
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:list" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_decrypt_to_local" class="Symfony\Bundle\FrameworkBundle\Command\SecretsDecryptToLocalCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:decrypt-to-local" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_encrypt_from_local" class="Symfony\Bundle\FrameworkBundle\Command\SecretsEncryptFromLocalCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:encrypt-from-local" />
|
||||
</service>
|
||||
</services>
|
||||
</container>
|
||||
|
@ -5,27 +5,36 @@
|
||||
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
|
||||
|
||||
<services>
|
||||
<service id="secrets.encoder.sodium" class="Symfony\Bundle\FrameworkBundle\Secret\Encoder\SodiumEncoder">
|
||||
<argument />
|
||||
<service id="secrets.vault" class="Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault">
|
||||
<argument>%kernel.project_dir%/config/secrets/%kernel.environment%</argument>
|
||||
<argument type="service" id="secrets.decryption_key" on-invalid="ignore" />
|
||||
</service>
|
||||
|
||||
<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"/>
|
||||
<!--
|
||||
LazyString::fromCallable() is used as a wrapper to lazily read the SYMFONY_DECRYPTION_SECRET var from the env.
|
||||
By overriding this service and using the same strategy, the decryption key can be fetched lazily from any other service if needed.
|
||||
-->
|
||||
<service id="secrets.decryption_key" class="Symfony\Component\DependencyInjection\LazyString">
|
||||
<factory class="Symfony\Component\DependencyInjection\LazyString" method="fromCallable" />
|
||||
<argument type="service">
|
||||
<service class="Closure">
|
||||
<factory class="Closure" method="fromCallable" />
|
||||
<argument type="collection">
|
||||
<argument type="service" id="service_container" />
|
||||
<argument>getEnv</argument>
|
||||
</argument>
|
||||
</service>
|
||||
</argument>
|
||||
<argument>base64:default::SYMFONY_DECRYPTION_SECRET</argument>
|
||||
</service>
|
||||
|
||||
<service id="secrets.storage.chain" class="Symfony\Bundle\FrameworkBundle\Secret\Storage\ChainSecretStorage">
|
||||
<argument type="tagged_iterator" tag="secret_storage"/>
|
||||
<service id="secrets.local_vault" class="Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault">
|
||||
<argument>%kernel.project_dir%/.env.local</argument>
|
||||
</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>
|
||||
|
||||
<service id="secrets.env_processor" class="Symfony\Bundle\FrameworkBundle\Secret\SecretEnvVarProcessor">
|
||||
<argument type="service" id="secrets.storage.cache" />
|
||||
<service id="secrets.env_var_processor" class="Symfony\Bundle\FrameworkBundle\Secrets\SecretEnvVarProcessor">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="container.env_var_processor" />
|
||||
</service>
|
||||
</services>
|
||||
|
@ -1,39 +0,0 @@
|
||||
<?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;
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
<?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,48 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
<?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;
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
<?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;
|
||||
}
|
49
src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php
Normal file
49
src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?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\Secrets;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractVault
|
||||
{
|
||||
protected $lastMessage;
|
||||
|
||||
public function getLastMessage(): ?string
|
||||
{
|
||||
return $this->lastMessage;
|
||||
}
|
||||
|
||||
abstract public function generateKeys(bool $override = false): bool;
|
||||
|
||||
abstract public function seal(string $name, string $value): void;
|
||||
|
||||
abstract public function reveal(string $name): ?string;
|
||||
|
||||
abstract public function remove(string $name): bool;
|
||||
|
||||
abstract public function list(bool $reveal = false): array;
|
||||
|
||||
protected function validateName(string $name): void
|
||||
{
|
||||
if (!preg_match('/^\w++$/D', $name)) {
|
||||
throw new \LogicException(sprintf('Invalid secret name "%s": only "word" characters are allowed.', $name));
|
||||
}
|
||||
}
|
||||
|
||||
protected function getPrettyPath(string $path)
|
||||
{
|
||||
return str_replace(getcwd().\DIRECTORY_SEPARATOR, '', $path);
|
||||
}
|
||||
}
|
110
src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php
Normal file
110
src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php
Normal 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\Secrets;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class DotenvVault extends AbstractVault
|
||||
{
|
||||
private $dotenvFile;
|
||||
|
||||
public function __construct(string $dotenvFile)
|
||||
{
|
||||
$this->dotenvFile = strtr($dotenvFile, '/', \DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function generateKeys(bool $override = false): bool
|
||||
{
|
||||
$this->lastMessage = 'The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function seal(string $name, string $value): void
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
$v = str_replace("'", "'\\''", $value);
|
||||
|
||||
$content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : '';
|
||||
$content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)/m", "$k='$v'", $content, -1, $count);
|
||||
|
||||
if (!$count) {
|
||||
$content .= "$k='$v'\n";
|
||||
}
|
||||
|
||||
file_put_contents($this->dotenvFile, $content);
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" %s in "%s".', $name, $count ? 'added' : 'updated', $this->getPrettyPath($this->dotenvFile));
|
||||
}
|
||||
|
||||
public function reveal(string $name): ?string
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
$v = \is_string($_SERVER[$k] ?? null) ? $_SERVER[$k] : ($_ENV[$k] ?? null);
|
||||
|
||||
if (null === $v) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
public function remove(string $name): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
|
||||
$content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : '';
|
||||
$content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)\n?/m", '', $content, -1, $count);
|
||||
|
||||
if ($count) {
|
||||
file_put_contents($this->dotenvFile, $content);
|
||||
$this->lastMessage = sprintf('Secret "%s" removed from file "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function list(bool $reveal = false): array
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$secrets = [];
|
||||
|
||||
foreach ($_ENV as $k => $v) {
|
||||
if (preg_match('/^(\w+)_SECRET$/D', $k, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $v : null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($_SERVER as $k => $v) {
|
||||
if (\is_string($v) && preg_match('/^(\w+)_SECRET$/D', $k, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $v : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $secrets;
|
||||
}
|
||||
}
|
@ -9,23 +9,26 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secret;
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secrets;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Exception\SecretNotFoundException;
|
||||
use Symfony\Bundle\FrameworkBundle\Secret\Storage\SecretStorageInterface;
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
private $secretStorage;
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(SecretStorageInterface $secretStorage)
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->secretStorage = $secretStorage;
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,10 +46,14 @@ class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
||||
*/
|
||||
public function getEnv($prefix, $name, \Closure $getEnv)
|
||||
{
|
||||
try {
|
||||
return $this->secretStorage->getSecret($name);
|
||||
} catch (SecretNotFoundException $e) {
|
||||
throw new EnvNotFoundException($e->getMessage(), 0, $e);
|
||||
if (null !== $this->localVault && null !== $secret = $this->localVault->reveal($name)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
if (null !== $secret = $this->vault->reveal($name)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
throw new EnvNotFoundException($this->vault->getLastMessage() ?? sprintf('Secret "%s" not found or decryption key is missing.', $name));
|
||||
}
|
||||
}
|
176
src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php
Normal file
176
src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?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\Secrets;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class SodiumVault extends AbstractVault
|
||||
{
|
||||
private $encryptionKey;
|
||||
private $decryptionKey;
|
||||
private $pathPrefix;
|
||||
|
||||
/**
|
||||
* @param string|object|null $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault
|
||||
* or null to store generated keys in the provided $secretsDir
|
||||
*/
|
||||
public function __construct(string $secretsDir, $decryptionKey = null)
|
||||
{
|
||||
if (!\function_exists('sodium_crypto_box_seal')) {
|
||||
throw new \LogicException('The "sodium" PHP extension is required to deal with secrets. Alternatively, try running "composer require paragonie/sodium_compat" if you cannot enable the extension."');
|
||||
}
|
||||
|
||||
if (null !== $decryptionKey && !\is_string($decryptionKey) && !(\is_object($decryptionKey) && method_exists($decryptionKey, '__toString'))) {
|
||||
throw new \TypeError(sprintf('Decryption key should be a string or an object that implements the __toString() method, %s given.', \gettype($decryptionKey)));
|
||||
}
|
||||
|
||||
if (!is_dir($secretsDir) && !@mkdir($secretsDir, 0777, true) && !is_dir($secretsDir)) {
|
||||
throw new \RuntimeException(sprintf('Unable to create the secrets directory (%s)', $secretsDir));
|
||||
}
|
||||
|
||||
$this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.';
|
||||
$this->decryptionKey = $decryptionKey;
|
||||
}
|
||||
|
||||
public function generateKeys(bool $override = false): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
|
||||
if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) {
|
||||
throw new \LogicException('Cannot generate keys when a decryption key has been provided while instantiating the vault.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->loadKeys();
|
||||
} catch (\LogicException $e) {
|
||||
// ignore failures to load keys
|
||||
}
|
||||
|
||||
if ('' !== $this->decryptionKey && !file_exists($this->pathPrefix.'sodium.encrypt.public')) {
|
||||
$this->export('sodium.encrypt.public', $this->encryptionKey);
|
||||
}
|
||||
|
||||
if (!$override && null !== $this->encryptionKey) {
|
||||
$this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->decryptionKey = sodium_crypto_box_keypair();
|
||||
$this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
|
||||
|
||||
$this->export('sodium.encrypt.public', $this->encryptionKey);
|
||||
$this->export('sodium.decrypt.private', $this->decryptionKey);
|
||||
|
||||
$this->lastMessage = sprintf('Sodium keys have been generated at "%s*.{public,private}".', $this->getPrettyPath($this->pathPrefix));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function seal(string $name, string $value): void
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$this->loadKeys();
|
||||
$this->export($name.'.'.substr_replace(md5($name), '.sodium', -26), sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey)));
|
||||
$this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
}
|
||||
|
||||
public function reveal(string $name): ?string
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
|
||||
if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->loadKeys();
|
||||
|
||||
if ('' === $this->decryptionKey) {
|
||||
$this->lastMessage = sprintf('Secrets cannot be revealed as no decryption key was found in "%s".', $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return sodium_crypto_box_seal_open(include $file, $this->decryptionKey);
|
||||
}
|
||||
|
||||
public function remove(string $name): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
|
||||
if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return @unlink($file) || !file_exists($file);
|
||||
}
|
||||
|
||||
public function list(bool $reveal = false): array
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$secrets = [];
|
||||
$regexp = sprintf('{^%s(\w++)\.[0-9a-f]{6}\.sodium$}D', preg_quote(basename($this->pathPrefix)));
|
||||
|
||||
foreach (scandir(\dirname($this->pathPrefix)) as $name) {
|
||||
if (preg_match($regexp, $name, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $this->reveal($m[1]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $secrets;
|
||||
}
|
||||
|
||||
private function loadKeys(): void
|
||||
{
|
||||
if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($this->pathPrefix.'sodium.decrypt.private')) {
|
||||
$this->decryptionKey = (string) include $this->pathPrefix.'sodium.decrypt.private';
|
||||
}
|
||||
|
||||
if (file_exists($this->pathPrefix.'sodium.encrypt.public')) {
|
||||
$this->encryptionKey = (string) include $this->pathPrefix.'sodium.encrypt.public';
|
||||
} elseif ('' !== $this->decryptionKey) {
|
||||
$this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
|
||||
} else {
|
||||
throw new \LogicException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix)));
|
||||
}
|
||||
}
|
||||
|
||||
private function export(string $file, string $data): void
|
||||
{
|
||||
$name = basename($this->pathPrefix.$file);
|
||||
$data = str_replace('%', '\x', rawurlencode($data));
|
||||
$data = sprintf("<?php // %s on %s\n\nreturn \"%s\";\n", $name, date('r'), $data);
|
||||
|
||||
if (false === file_put_contents($this->pathPrefix.$file, $data, LOCK_EX)) {
|
||||
$e = error_get_last();
|
||||
throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
@ -438,9 +438,10 @@ class ConfigurationTest extends TestCase
|
||||
],
|
||||
'error_controller' => 'error_controller',
|
||||
'secrets' => [
|
||||
'enabled' => false,
|
||||
'encrypted_secrets_dir' => '%kernel.project_dir%/config/secrets/%kernel.environment%',
|
||||
'encryption_key' => '%kernel.project_dir%/config/secrets/encryption_%kernel.environment%.key',
|
||||
'enabled' => true,
|
||||
'vault_directory' => '%kernel.project_dir%/config/secrets/%kernel.environment%',
|
||||
'local_dotenv_file' => '%kernel.project_dir%/.env.local',
|
||||
'decryption_env_var' => 'base64:default::SYMFONY_DECRYPTION_SECRET',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -1,71 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
<?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)));
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Secrets;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
class DotenvVaultTest extends TestCase
|
||||
{
|
||||
private $envFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->envFile = sys_get_temp_dir().'/sf_secrets.env.test';
|
||||
@unlink($this->envFile);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->envFile);
|
||||
}
|
||||
|
||||
public function testGenerateKeys()
|
||||
{
|
||||
$vault = new DotenvVault($this->envFile);
|
||||
|
||||
$this->assertFalse($vault->generateKeys());
|
||||
$this->assertSame('The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.', $vault->getLastMessage());
|
||||
}
|
||||
|
||||
public function testEncryptAndDecrypt()
|
||||
{
|
||||
$vault = new DotenvVault($this->envFile);
|
||||
|
||||
$plain = "plain\ntext";
|
||||
|
||||
$vault->seal('foo', $plain);
|
||||
|
||||
unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']);
|
||||
(new Dotenv(false))->load($this->envFile);
|
||||
|
||||
$decrypted = $vault->reveal('foo');
|
||||
$this->assertSame($plain, $decrypted);
|
||||
|
||||
$this->assertSame(['foo' => null], $vault->list());
|
||||
$this->assertSame(['foo' => $plain], $vault->list(true));
|
||||
|
||||
$this->assertTrue($vault->remove('foo'));
|
||||
$this->assertFalse($vault->remove('foo'));
|
||||
|
||||
unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']);
|
||||
(new Dotenv(false))->load($this->envFile);
|
||||
|
||||
$this->assertSame([], $vault->list());
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Secrets;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
class SodiumVaultTest extends TestCase
|
||||
{
|
||||
private $secretsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->secretsDir = sys_get_temp_dir().'/sf_secrets/test/';
|
||||
(new Filesystem())->remove($this->secretsDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem())->remove($this->secretsDir);
|
||||
}
|
||||
|
||||
public function testGenerateKeys()
|
||||
{
|
||||
$vault = new SodiumVault($this->secretsDir);
|
||||
|
||||
$this->assertTrue($vault->generateKeys());
|
||||
$this->assertFileExists($this->secretsDir.'/test.sodium.encrypt.public');
|
||||
$this->assertFileExists($this->secretsDir.'/test.sodium.decrypt.private');
|
||||
|
||||
$encKey = file_get_contents($this->secretsDir.'/test.sodium.encrypt.public');
|
||||
$decKey = file_get_contents($this->secretsDir.'/test.sodium.decrypt.private');
|
||||
|
||||
$this->assertFalse($vault->generateKeys());
|
||||
$this->assertStringEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey);
|
||||
$this->assertStringEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey);
|
||||
|
||||
$this->assertTrue($vault->generateKeys(true));
|
||||
$this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey);
|
||||
$this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey);
|
||||
}
|
||||
|
||||
public function testEncryptAndDecrypt()
|
||||
{
|
||||
$vault = new SodiumVault($this->secretsDir);
|
||||
$vault->generateKeys();
|
||||
|
||||
$plain = "plain\ntext";
|
||||
|
||||
$vault->seal('foo', $plain);
|
||||
|
||||
$decrypted = $vault->reveal('foo');
|
||||
$this->assertSame($plain, $decrypted);
|
||||
|
||||
$this->assertSame(['foo' => null], $vault->list());
|
||||
$this->assertSame(['foo' => $plain], $vault->list(true));
|
||||
|
||||
$this->assertTrue($vault->remove('foo'));
|
||||
$this->assertFalse($vault->remove('foo'));
|
||||
|
||||
$this->assertSame([], $vault->list());
|
||||
}
|
||||
}
|
@ -32,11 +32,13 @@
|
||||
"require-dev": {
|
||||
"doctrine/annotations": "~1.7",
|
||||
"doctrine/cache": "~1.0",
|
||||
"paragonie/sodium_compat": "^1.8",
|
||||
"symfony/asset": "^3.4|^4.0|^5.0",
|
||||
"symfony/browser-kit": "^4.3|^5.0",
|
||||
"symfony/console": "^4.3.4|^5.0",
|
||||
"symfony/css-selector": "^3.4|^4.0|^5.0",
|
||||
"symfony/dom-crawler": "^4.3|^5.0",
|
||||
"symfony/dotenv": "^4.3.6|^5.0",
|
||||
"symfony/polyfill-intl-icu": "~1.0",
|
||||
"symfony/form": "^4.3.4|^5.0",
|
||||
"symfony/expression-language": "^3.4|^4.0|^5.0",
|
||||
@ -69,7 +71,7 @@
|
||||
"symfony/asset": "<3.4",
|
||||
"symfony/browser-kit": "<4.3",
|
||||
"symfony/console": "<4.3",
|
||||
"symfony/dotenv": "<4.2",
|
||||
"symfony/dotenv": "<4.3.6",
|
||||
"symfony/dom-crawler": "<4.3",
|
||||
"symfony/http-client": "<4.4",
|
||||
"symfony/form": "<4.3",
|
||||
|
Reference in New Issue
Block a user