Proof of concept for encrypted secrets

This commit is contained in:
Tobias Schultze 2019-04-06 19:40:45 +02:00 committed by Nicolas Grekas
parent 594e7aef87
commit 8c8f62390a
12 changed files with 464 additions and 0 deletions

View File

@ -0,0 +1,68 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Command;
use Symfony\Bundle\FrameworkBundle\Secret\SecretStorageInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class SecretsAddCommand extends Command
{
protected static $defaultName = 'secrets:add';
/**
* @var SecretStorageInterface
*/
private $secretStorage;
public function __construct(SecretStorageInterface $secretStorage)
{
$this->secretStorage = $secretStorage;
parent::__construct();
}
protected function configure()
{
$this
->setDescription('Adds a secret with the key.')
->addArgument(
'key',
InputArgument::REQUIRED
)
->addArgument(
'secret',
InputArgument::REQUIRED
)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$key = $input->getArgument('key');
$secret = $input->getArgument('secret');
$this->secretStorage->putSecret($key, $secret);
}
protected function interact(InputInterface $input, OutputInterface $output)
{
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question('Key of the secret: ', $input->getArgument('key'));
$key = $helper->ask($input, $output, $question);
$input->setArgument('key', $key);
$question = new Question('Plaintext secret value: ', $input->getArgument('secret'));
$question->setHidden(true);
$secret = $helper->ask($input, $output, $question);
$input->setArgument('secret', $secret);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class SecretsGenerateKeyCommand extends Command
{
protected static $defaultName = 'secrets:generate-key';
protected function configure()
{
$this
->setDescription('Prints a randomly generated encryption key.')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$encryptionKey = sodium_crypto_stream_keygen();
$output->write($encryptionKey, false, OutputInterface::OUTPUT_RAW);
sodium_memzero($encryptionKey);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Command;
use Symfony\Bundle\FrameworkBundle\Secret\SecretStorageInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class SecretsListCommand extends Command
{
protected static $defaultName = 'secrets:list';
/**
* @var SecretStorageInterface
*/
private $secretStorage;
public function __construct(SecretStorageInterface $secretStorage)
{
$this->secretStorage = $secretStorage;
parent::__construct();
}
protected function configure()
{
$this
->setDescription('Lists all secrets.')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$table = new Table($output);
$table->setHeaders(['key', 'plaintext secret']);
foreach ($this->secretStorage->listSecrets() as $key => $secret) {
$table->addRow([$key, $secret]);
}
$table->render();
}
}

View File

@ -115,10 +115,29 @@ 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')
->canBeEnabled()
->children()
->scalarNode('encrypted_secrets_dir')->end()
->scalarNode('encryption_key')->end()
//->scalarNode('public_key')->end()
//->scalarNode('private_key')->end()
->scalarNode('decrypted_secrets_cache')->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,20 @@ class FrameworkExtension extends Extension
;
}
private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
{
if (!$this->isConfigEnabled($container, $config)) {
$container->removeDefinition('console.command.secrets_add');
return;
}
$loader->load('secrets.xml');
$container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']);
$container->getDefinition('secrets.storage.files')->replaceArgument(1, $config['encryption_key']);
}
private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
{
if (!$this->isConfigEnabled($container, $config)) {

View File

@ -200,5 +200,19 @@
<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_add" class="Symfony\Bundle\FrameworkBundle\Command\SecretsAddCommand">
<argument type="service" id="secrets.storage.cache" />
<tag name="console.command" command="secrets:add" />
</service>
<service id="console.command.secrets_generate_key" class="Symfony\Bundle\FrameworkBundle\Command\SecretsGenerateKeyCommand">
<tag name="console.command" command="secrets:generate-key" />
</service>
<service id="console.command.secrets_list" class="Symfony\Bundle\FrameworkBundle\Command\SecretsListCommand">
<argument type="service" id="secrets.storage.cache" />
<tag name="console.command" command="secrets:list" />
</service>
</services>
</container>

View File

@ -0,0 +1,23 @@
<?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.storage.files" class="Symfony\Bundle\FrameworkBundle\Secret\FilesSecretStorage">
<argument />
<argument />
</service>
<service id="secrets.storage.cache" class="Symfony\Bundle\FrameworkBundle\Secret\CachedSecretStorage">
<argument type="service" id="secrets.storage.files" />
<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" />
<tag name="container.env_var_processor" />
</service>
</services>
</container>

View File

@ -0,0 +1,78 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
class CachedSecretStorage implements SecretStorageInterface
{
/**
* @var SecretStorageInterface
*/
private $decoratedStorage;
/**
* @var CacheItemPoolInterface
*/
private $cache;
public function __construct(SecretStorageInterface $decoratedStorage, CacheItemPoolInterface $cache)
{
$this->decoratedStorage = $decoratedStorage;
$this->cache = $cache;
}
public function getSecret(string $key): string
{
$cacheItem = $this->cache->getItem('secrets.php');
if ($cacheItem->isHit()) {
$secrets = $cacheItem->get();
if (isset($secrets[$key])) {
return $secrets[$key];
}
}
$this->regenerateCache($cacheItem);
return $this->decoratedStorage->getSecret($key);
}
public function putSecret(string $key, string $secret): void
{
$this->decoratedStorage->putSecret($key, $secret);
$this->regenerateCache();
}
public function deleteSecret(string $key): void
{
$this->decoratedStorage->deleteSecret($key);
$this->regenerateCache();
}
public function listSecrets(): iterable
{
$cacheItem = $this->cache->getItem('secrets.php');
if ($cacheItem->isHit()) {
return $cacheItem->get();
}
return $this->regenerateCache($cacheItem);
}
private function regenerateCache(?CacheItemInterface $cacheItem = null): array
{
$cacheItem = $cacheItem ?? $this->cache->getItem('secrets.php');
$secrets = [];
foreach ($this->decoratedStorage->listSecrets() as $key => $secret) {
$secrets[$key] = $secret;
}
$cacheItem->set($secrets);
$this->cache->save($cacheItem);
return $secrets;
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
class EncryptedMessage
{
/**
* @var string
*/
private $ciphertext;
/**
* @var string
*/
private $nonce;
public function __construct(string $ciphertext, string $nonce)
{
$this->ciphertext = $ciphertext;
$this->nonce = $nonce;
}
public function __toString()
{
return $this->nonce.$this->ciphertext;
}
public function getCiphertext(): string
{
return $this->ciphertext;
}
public function getNonce(): string
{
return $this->nonce;
}
public static function createFromString(string $message): self
{
if (\strlen($message) < SODIUM_CRYPTO_STREAM_NONCEBYTES) {
throw new \RuntimeException('Invalid ciphertext. Message is too short.');
}
$nonce = substr($message, 0, SODIUM_CRYPTO_STREAM_NONCEBYTES);
$ciphertext = substr($message, SODIUM_CRYPTO_STREAM_NONCEBYTES);
return new self($ciphertext, $nonce);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace Symfony\Bundle\FrameworkBundle\Secret;
class FilesSecretStorage implements SecretStorageInterface
{
/**
* @var string
*/
private $secretsFolder;
/**
* @var string
*/
private $encryptionKey;
public function __construct(string $secretsFolder, string $encryptionKey)
{
$this->secretsFolder = $secretsFolder;
$this->encryptionKey = $encryptionKey;
}
public function getSecret(string $key): string
{
return $this->decryptFile($this->getFilePath($key));
}
public function putSecret(string $key, string $secret): void
{
$nonce = random_bytes(SODIUM_CRYPTO_STREAM_NONCEBYTES);
$ciphertext = sodium_crypto_stream_xor($secret, $nonce, $this->encryptionKey);
sodium_memzero($secret);
$message = new EncryptedMessage($ciphertext, $nonce);
file_put_contents($this->getFilePath($key), (string) $message);
}
public function deleteSecret(string $key): void
{
unlink($this->getFilePath($key));
}
public function listSecrets(): iterable
{
foreach (scandir($this->secretsFolder) as $fileName) {
if ('.' === $fileName || '..' === $fileName) {
continue;
}
$key = basename($fileName, '.bin');
yield $key => $this->getSecret($key);
}
}
private function decryptFile(string $filePath): string
{
$encrypted = file_get_contents($filePath);
$message = EncryptedMessage::createFromString($encrypted);
return sodium_crypto_stream_xor($message->getCiphertext(), $message->getNonce(), $this->encryptionKey);
}
private function getFilePath(string $key): string
{
return $this->secretsFolder.$key.'.bin';
}
}

View File

@ -0,0 +1,42 @@
<?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;
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
class SecretEnvVarProcessor implements EnvVarProcessorInterface
{
private $secretStorage;
public function __construct(SecretStorageInterface $secretStorage)
{
$this->secretStorage = $secretStorage;
}
/**
* {@inheritdoc}
*/
public static function getProvidedTypes()
{
return [
'secret' => 'string',
];
}
/**
* {@inheritdoc}
*/
public function getEnv($prefix, $name, \Closure $getEnv)
{
return $this->secretStorage->getSecret($name);
}
}

View File

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