Restrict secrets management to sodium+filesystem

This commit is contained in:
Nicolas Grekas 2019-10-14 12:34:37 +02:00
parent 02b5d740e5
commit c4653e1f65
35 changed files with 1074 additions and 939 deletions

View File

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

View File

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

View File

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

View File

@ -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.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View File

@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\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;
}
}

View File

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

View 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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