feature #33997 [FrameworkBundle] Add secrets:* commands and %env(secret:...)% processor to deal with secrets seamlessly (Tobion, jderusse, nicolas-grekas)

This PR was merged into the 4.4 branch.

Discussion
----------

[FrameworkBundle] Add `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #27351
| License       | MIT
| Doc PR        | symfony/symfony-docs/pull/11396

This PR continues #31101, please see there for previous discussions. The attached patch has been fine-tuned on https://github.com/nicolas-grekas/symfony/pull/33 with @jderusse.

This PR is more opinionated and thus a lot simpler than #31101: only Sodium is supported to encrypt/decrypt (polyfill possible), and only local filesystem is available as a storage, with little to no extension point. That's on purpose: the goal here is to provide an experience, not software building blocks. In 5.1, this might be extended and might lead to a new component, but we'd first need reports from real-world needs. Having this straight-to-the-point in 4.4 will allow gathering these needs (if they exist) and will immediately provide a nice workflow for the need we do want to solve now: forwarding secrets from dev to prod using git in a secure way.

The workflow this will allow is the following:
- public/private key pairs are generated in the `config/secrets/%kernel.environment%/` folder using `bin/console secrets:generate-keys`
- for the prod env, the corresponding private key should be deployed to the server using whatever means the hosting provider allows - this key MUST NOT be committed
- the public key is used to encrypt secrets and thus *may* be committed in the git repository to allow anyone *that can commit* to add secrets - this is done using `bin/console secrets:set`

DI configuration can reference secrets using `%env(secret:...)%` in e.g `services.yaml`.
There is also `bin/console secrets:remove` and `bin/console debug:secrets` to complete the toolbox.

In terms of design, vs #31101, this groups the dual "encoder" + "storage" concepts in a single "vault" one. That's part of what makes this PR simpler.

That's all folks :)

Commits
-------

c4653e1f65 Restrict secrets management to sodium+filesystem
02b5d740e5 Add secrets management
8c8f62390a Proof of concept for encrypted secrets
This commit is contained in:
Fabien Potencier 2019-10-20 21:11:11 +02:00
commit 16d528504c
21 changed files with 1279 additions and 1 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,6 +17,7 @@ CHANGELOG
* Added new `error_controller` configuration to handle system exceptions
* Added sort option for `translation:update` command.
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
* Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly.
4.3.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 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

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

@ -0,0 +1,108 @@
<?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\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 = 'secrets:list';
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('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.
<info>%command.full_name%</info>
When the option <info>--reveal</info> is provided, the decrypted secrets are also displayed.
<info>%command.full_name% --reveal</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$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()));
}
$secrets = $this->vault->list($reveal);
$localSecrets = null !== $this->localVault ? $this->localVault->list($reveal) : null;
$rows = [];
$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)];
}
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

@ -0,0 +1,82 @@
<?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 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 $vault;
private $localVault;
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure()
{
$this
->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 removes a secret from the vault.
<info>%command.full_name% <name></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 ($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

@ -115,10 +115,27 @@ class Configuration implements ConfigurationInterface
$this->addRobotsIndexSection($rootNode);
$this->addHttpClientSection($rootNode);
$this->addMailerSection($rootNode);
$this->addSecretsSection($rootNode);
return $treeBuilder;
}
private function addSecretsSection(ArrayNodeDefinition $rootNode)
{
$rootNode
->children()
->arrayNode('secrets')
->canBeDisabled()
->children()
->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()
;
}
private function addCsrfSection(ArrayNodeDefinition $rootNode)
{
$rootNode

View File

@ -334,6 +334,7 @@ class FrameworkExtension extends Extension
$this->registerRouterConfiguration($config['router'], $container, $loader);
$this->registerAnnotationsConfiguration($config['annotations'], $container, $loader);
$this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader);
$this->registerSecretsConfiguration($config['secrets'], $container, $loader);
if ($this->isConfigEnabled($container, $config['serializer'])) {
if (!class_exists('Symfony\Component\Serializer\Serializer')) {
@ -1441,6 +1442,34 @@ class FrameworkExtension extends Extension
;
}
private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
{
if (!$this->isConfigEnabled($container, $config)) {
$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->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']);
if (!$config['local_dotenv_file']) {
$container->removeDefinition('secrets.local_vault');
}
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)
{
if (!$this->isConfigEnabled($container, $config)) {

View File

@ -200,5 +200,41 @@
<argument type="service" id="debug.file_link_formatter" on-invalid="null" />
<tag name="console.command" command="debug:error-renderer" />
</service>
<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.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\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.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

@ -0,0 +1,41 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<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>
<!--
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.local_vault" class="Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault">
<argument>%kernel.project_dir%/.env.local</argument>
</service>
<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>
</container>

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

@ -0,0 +1,59 @@
<?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;
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 $vault;
private $localVault;
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
}
/**
* {@inheritdoc}
*/
public static function getProvidedTypes()
{
return [
'secret' => 'string',
];
}
/**
* {@inheritdoc}
*/
public function getEnv($prefix, $name, \Closure $getEnv)
{
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

@ -437,6 +437,12 @@ class ConfigurationTest extends TestCase
'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class),
],
'error_controller' => 'error_controller',
'secrets' => [
'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

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